@vex-chat/libvex 6.4.1 → 6.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/dist/Client.d.ts +3 -0
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +172 -32
- package/dist/Client.js.map +1 -1
- package/dist/Storage.d.ts +2 -0
- package/dist/Storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.d.ts +1 -0
- package/dist/__tests__/harness/memory-storage.d.ts.map +1 -1
- package/dist/__tests__/harness/memory-storage.js +6 -0
- package/dist/__tests__/harness/memory-storage.js.map +1 -1
- package/dist/storage/sqlite.d.ts +3 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +36 -1
- package/dist/storage/sqlite.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +234 -47
- package/src/Storage.ts +2 -0
- package/src/__tests__/harness/memory-storage.ts +7 -0
- package/src/storage/sqlite.ts +41 -2
package/src/Client.ts
CHANGED
|
@@ -149,6 +149,26 @@ export class DeviceApprovalRequiredError extends Error {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
function cloneNullableBytes(value: null | Uint8Array): null | Uint8Array {
|
|
153
|
+
return value ? new Uint8Array(value) : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function cloneSessionCrypto(session: SessionCrypto): SessionCrypto {
|
|
157
|
+
return {
|
|
158
|
+
...session,
|
|
159
|
+
CKr: cloneNullableBytes(session.CKr),
|
|
160
|
+
CKs: cloneNullableBytes(session.CKs),
|
|
161
|
+
DHr: cloneNullableBytes(session.DHr),
|
|
162
|
+
DHsPrivate: new Uint8Array(session.DHsPrivate),
|
|
163
|
+
DHsPublic: new Uint8Array(session.DHsPublic),
|
|
164
|
+
fingerprint: new Uint8Array(session.fingerprint),
|
|
165
|
+
publicKey: new Uint8Array(session.publicKey),
|
|
166
|
+
RK: new Uint8Array(session.RK),
|
|
167
|
+
SK: new Uint8Array(session.SK),
|
|
168
|
+
skippedKeys: { ...session.skippedKeys },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
152
172
|
function debugLibvexDm(
|
|
153
173
|
msg: string,
|
|
154
174
|
data?: Record<string, boolean | null | number | string | undefined>,
|
|
@@ -156,6 +176,9 @@ function debugLibvexDm(
|
|
|
156
176
|
if (!libvexDebugDmEnabled()) {
|
|
157
177
|
return;
|
|
158
178
|
}
|
|
179
|
+
if (isHeartbeatDebugMessage(msg, data) && libvexDebugLevel() !== "trace") {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
159
182
|
const payload = data ? `${msg} ${JSON.stringify(data)}` : msg;
|
|
160
183
|
// eslint-disable-next-line no-console -- gated by LIBVEX_DEBUG_DM; remove when debugging is done
|
|
161
184
|
console.error(`[libvex:debug-dm] ${payload}`);
|
|
@@ -168,6 +191,14 @@ function ignoreSocketTeardown(err: unknown): void {
|
|
|
168
191
|
throw err;
|
|
169
192
|
}
|
|
170
193
|
|
|
194
|
+
function isHeartbeatDebugMessage(
|
|
195
|
+
msg: string,
|
|
196
|
+
data?: Record<string, boolean | null | number | string | undefined>,
|
|
197
|
+
): boolean {
|
|
198
|
+
if (/\b(?:ping|pong)\b/i.test(msg)) return true;
|
|
199
|
+
return data?.["type"] === "ping" || data?.["type"] === "pong";
|
|
200
|
+
}
|
|
201
|
+
|
|
171
202
|
function isRecord(x: unknown): x is Record<string, unknown> {
|
|
172
203
|
return typeof x === "object" && x !== null;
|
|
173
204
|
}
|
|
@@ -202,6 +233,26 @@ function libvexDebugDmEnabled(): boolean {
|
|
|
202
233
|
}
|
|
203
234
|
}
|
|
204
235
|
|
|
236
|
+
function libvexDebugLevel(): "debug" | "trace" {
|
|
237
|
+
try {
|
|
238
|
+
const g = Object.getOwnPropertyDescriptor(globalThis, "\u0070rocess");
|
|
239
|
+
if (!g) return "debug";
|
|
240
|
+
const proc: unknown = typeof g.get === "function" ? g.get() : g.value;
|
|
241
|
+
if (typeof proc !== "object" || proc === null) return "debug";
|
|
242
|
+
const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
|
|
243
|
+
if (!envDesc) return "debug";
|
|
244
|
+
const env: unknown =
|
|
245
|
+
typeof envDesc.get === "function" ? envDesc.get() : envDesc.value;
|
|
246
|
+
if (typeof env !== "object" || env === null) return "debug";
|
|
247
|
+
const value = String(
|
|
248
|
+
Reflect.get(env, "LIBVEX_DEBUG_LEVEL") ?? "",
|
|
249
|
+
).toLowerCase();
|
|
250
|
+
return value === "trace" || value === "2" ? "trace" : "debug";
|
|
251
|
+
} catch {
|
|
252
|
+
return "debug";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
205
256
|
function sleep(ms: number): Promise<void> {
|
|
206
257
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
207
258
|
}
|
|
@@ -1299,16 +1350,17 @@ export class Client {
|
|
|
1299
1350
|
|
|
1300
1351
|
private readonly dbPath: string;
|
|
1301
1352
|
|
|
1353
|
+
private readonly decryptFailureCounts = new Map<string, number>();
|
|
1354
|
+
|
|
1302
1355
|
private device?: Device;
|
|
1303
1356
|
|
|
1304
1357
|
private deviceRecords: Record<string, Device> = {};
|
|
1305
|
-
|
|
1306
1358
|
// ── Event subscription (composition over inheritance) ───────────────
|
|
1307
1359
|
private readonly emitter = new EventEmitter<ClientEvents>();
|
|
1360
|
+
|
|
1308
1361
|
private fetchingMail: boolean = false;
|
|
1309
1362
|
|
|
1310
1363
|
private firstMailFetch = true;
|
|
1311
|
-
|
|
1312
1364
|
private readonly forwarded = new Set<string>();
|
|
1313
1365
|
private readonly host: string;
|
|
1314
1366
|
private readonly http: AxiosInstance;
|
|
@@ -1316,13 +1368,13 @@ export class Client {
|
|
|
1316
1368
|
private readonly httpAbortController = new AbortController();
|
|
1317
1369
|
private readonly idKeys: KeyPair | null;
|
|
1318
1370
|
private isAlive: boolean = true;
|
|
1319
|
-
private localMessageRetentionDays: number;
|
|
1320
1371
|
|
|
1372
|
+
private localMessageRetentionDays: number;
|
|
1321
1373
|
private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
|
|
1322
1374
|
null;
|
|
1323
1375
|
private readonly mailInterval?: NodeJS.Timeout;
|
|
1324
|
-
private manuallyClosing: boolean = false;
|
|
1325
1376
|
|
|
1377
|
+
private manuallyClosing: boolean = false;
|
|
1326
1378
|
/**
|
|
1327
1379
|
* Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
|
|
1328
1380
|
* Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
|
|
@@ -1336,19 +1388,19 @@ export class Client {
|
|
|
1336
1388
|
and finally falls back to username. */
|
|
1337
1389
|
/** Negative cache for user lookups that returned 404. TTL = 30 minutes. */
|
|
1338
1390
|
private readonly notFoundUsers = new Map<string, number>();
|
|
1391
|
+
|
|
1339
1392
|
private readonly options?: ClientOptions | undefined;
|
|
1340
1393
|
|
|
1341
1394
|
private pingInterval: null | ReturnType<typeof setTimeout> = null;
|
|
1342
|
-
|
|
1343
1395
|
/**
|
|
1344
1396
|
* Bumped when the WebSocket is torn down and re-opened so the previous
|
|
1345
1397
|
* `postAuth` loop exits instead of overlapping a new one.
|
|
1346
1398
|
*/
|
|
1347
1399
|
private postAuthVersion = 0;
|
|
1400
|
+
|
|
1348
1401
|
private readonly prefixes:
|
|
1349
1402
|
| { HTTP: "http://"; WS: "ws://" }
|
|
1350
1403
|
| { HTTP: "https://"; WS: "wss://" };
|
|
1351
|
-
|
|
1352
1404
|
private reading: boolean = false;
|
|
1353
1405
|
private retentionPurgeDebounce: null | ReturnType<typeof setTimeout> = null;
|
|
1354
1406
|
private readonly seenMailIDs: Set<string> = new Set();
|
|
@@ -2179,10 +2231,27 @@ export class Client {
|
|
|
2179
2231
|
}
|
|
2180
2232
|
|
|
2181
2233
|
private acknowledgeInboundMail(mail: MailWS): void {
|
|
2234
|
+
this.decryptFailureCounts.delete(mail.mailID);
|
|
2182
2235
|
this.seenMailIDs.add(mail.mailID);
|
|
2183
2236
|
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
2184
2237
|
}
|
|
2185
2238
|
|
|
2239
|
+
private acknowledgeRepeatedDecryptFailure(
|
|
2240
|
+
mail: MailWS,
|
|
2241
|
+
count: number,
|
|
2242
|
+
): void {
|
|
2243
|
+
if (count < 2) return;
|
|
2244
|
+
if (libvexDebugDmEnabled()) {
|
|
2245
|
+
debugLibvexDm("readMail: acknowledge repeated decrypt failure", {
|
|
2246
|
+
attempts: count,
|
|
2247
|
+
mailID: mail.mailID,
|
|
2248
|
+
sender: mail.sender,
|
|
2249
|
+
thisDevice: this.getDevice().deviceID,
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
this.acknowledgeInboundMail(mail);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2186
2255
|
private async approveDeviceRequest(requestID: string): Promise<Device> {
|
|
2187
2256
|
const req = await this.getDeviceRegistrationRequest(requestID);
|
|
2188
2257
|
if (!req) {
|
|
@@ -2615,6 +2684,7 @@ export class Client {
|
|
|
2615
2684
|
private async deletePermission(permissionID: string): Promise<void> {
|
|
2616
2685
|
await this.http.delete(this.getHost() + "/permission/" + permissionID);
|
|
2617
2686
|
}
|
|
2687
|
+
|
|
2618
2688
|
private async deleteServer(serverID: string): Promise<void> {
|
|
2619
2689
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2620
2690
|
}
|
|
@@ -2631,7 +2701,6 @@ export class Client {
|
|
|
2631
2701
|
}
|
|
2632
2702
|
return "";
|
|
2633
2703
|
}
|
|
2634
|
-
|
|
2635
2704
|
/**
|
|
2636
2705
|
* Gets a list of permissions for a server.
|
|
2637
2706
|
*
|
|
@@ -3140,8 +3209,6 @@ export class Client {
|
|
|
3140
3209
|
return decodeAxios(UserArrayCodec, res.data);
|
|
3141
3210
|
}
|
|
3142
3211
|
|
|
3143
|
-
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3144
|
-
|
|
3145
3212
|
private async handleNotify(msg: NotifyMsg) {
|
|
3146
3213
|
switch (msg.event) {
|
|
3147
3214
|
case "deviceRequest": {
|
|
@@ -3210,6 +3277,8 @@ export class Client {
|
|
|
3210
3277
|
this.emitter.emit("ready");
|
|
3211
3278
|
}
|
|
3212
3279
|
|
|
3280
|
+
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3281
|
+
|
|
3213
3282
|
private initSocket() {
|
|
3214
3283
|
try {
|
|
3215
3284
|
if (!this.token) {
|
|
@@ -3405,7 +3474,11 @@ export class Client {
|
|
|
3405
3474
|
if (this.isManualCloseInFlight()) {
|
|
3406
3475
|
return;
|
|
3407
3476
|
}
|
|
3408
|
-
if (
|
|
3477
|
+
if (
|
|
3478
|
+
message.direction === "outgoing" &&
|
|
3479
|
+
!message.forward &&
|
|
3480
|
+
message.group === null
|
|
3481
|
+
) {
|
|
3409
3482
|
void this.forward(message);
|
|
3410
3483
|
}
|
|
3411
3484
|
|
|
@@ -3650,6 +3723,23 @@ export class Client {
|
|
|
3650
3723
|
return;
|
|
3651
3724
|
}
|
|
3652
3725
|
|
|
3726
|
+
if (await this.database.hasMessage(mail.mailID)) {
|
|
3727
|
+
if (libvexDebugDmEnabled()) {
|
|
3728
|
+
try {
|
|
3729
|
+
debugLibvexDm("readMail: skip (stored mailID)", {
|
|
3730
|
+
mailID: mail.mailID,
|
|
3731
|
+
thisDevice: this.getDevice().deviceID,
|
|
3732
|
+
});
|
|
3733
|
+
} catch {
|
|
3734
|
+
debugLibvexDm("readMail: skip (stored mailID)", {
|
|
3735
|
+
mailID: mail.mailID,
|
|
3736
|
+
});
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
this.acknowledgeInboundMail(mail);
|
|
3740
|
+
return;
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3653
3743
|
if (this.manuallyClosing) {
|
|
3654
3744
|
if (libvexDebugDmEnabled()) {
|
|
3655
3745
|
debugLibvexDm("readMail: skip (manually closing)", {
|
|
@@ -4056,40 +4146,110 @@ export class Client {
|
|
|
4056
4146
|
return;
|
|
4057
4147
|
}
|
|
4058
4148
|
|
|
4149
|
+
const originalSession = cloneSessionCrypto(session);
|
|
4059
4150
|
const firstInboundFromSubsequent = !session.DHr;
|
|
4151
|
+
let candidateSession = cloneSessionCrypto(session);
|
|
4060
4152
|
if (firstInboundFromSubsequent) {
|
|
4061
|
-
|
|
4062
|
-
// First inbound after X3DH initial mail
|
|
4063
|
-
//
|
|
4064
|
-
//
|
|
4065
|
-
//
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4153
|
+
candidateSession.DHr = ratchetHeader.dhPub;
|
|
4154
|
+
// First inbound after X3DH initial mail can be either:
|
|
4155
|
+
// - peer's bootstrap send chain if they replied before seeing
|
|
4156
|
+
// one of our subsequent messages; or
|
|
4157
|
+
// - a real DH ratchet if they already received one from us.
|
|
4158
|
+
// Try bootstrap first for backwards compatibility, then fall
|
|
4159
|
+
// back to the DH-ratchet interpretation if HMAC disagrees.
|
|
4160
|
+
if (!candidateSession.CKr) {
|
|
4161
|
+
candidateSession.CKr = deriveBootstrapSendChain(
|
|
4162
|
+
candidateSession.RK,
|
|
4069
4163
|
);
|
|
4070
4164
|
}
|
|
4071
4165
|
} else if (
|
|
4072
|
-
hasRemoteDhChanged(
|
|
4166
|
+
hasRemoteDhChanged(
|
|
4167
|
+
candidateSession.DHr,
|
|
4168
|
+
ratchetHeader.dhPub,
|
|
4169
|
+
)
|
|
4073
4170
|
) {
|
|
4074
4171
|
await ratchetStepReceive(
|
|
4075
|
-
|
|
4172
|
+
candidateSession,
|
|
4076
4173
|
ratchetHeader.dhPub,
|
|
4077
4174
|
ratchetHeader.pn,
|
|
4078
4175
|
);
|
|
4079
4176
|
}
|
|
4080
4177
|
|
|
4081
|
-
|
|
4082
|
-
|
|
4178
|
+
let messageKey = takeReceiveMessageKey(
|
|
4179
|
+
candidateSession,
|
|
4083
4180
|
ratchetHeader.dhPub,
|
|
4084
4181
|
ratchetHeader.n,
|
|
4085
4182
|
);
|
|
4086
|
-
|
|
4183
|
+
let HMAC = xHMAC(mail, messageKey);
|
|
4184
|
+
|
|
4185
|
+
if (
|
|
4186
|
+
!XUtils.bytesEqual(HMAC, header) &&
|
|
4187
|
+
firstInboundFromSubsequent &&
|
|
4188
|
+
!originalSession.CKr
|
|
4189
|
+
) {
|
|
4190
|
+
const ratchetedCandidate =
|
|
4191
|
+
cloneSessionCrypto(originalSession);
|
|
4192
|
+
await ratchetStepReceive(
|
|
4193
|
+
ratchetedCandidate,
|
|
4194
|
+
ratchetHeader.dhPub,
|
|
4195
|
+
ratchetHeader.pn,
|
|
4196
|
+
);
|
|
4197
|
+
const ratchetedMessageKey = takeReceiveMessageKey(
|
|
4198
|
+
ratchetedCandidate,
|
|
4199
|
+
ratchetHeader.dhPub,
|
|
4200
|
+
ratchetHeader.n,
|
|
4201
|
+
);
|
|
4202
|
+
const ratchetedHMAC = xHMAC(
|
|
4203
|
+
mail,
|
|
4204
|
+
ratchetedMessageKey,
|
|
4205
|
+
);
|
|
4206
|
+
if (XUtils.bytesEqual(ratchetedHMAC, header)) {
|
|
4207
|
+
if (libvexDebugDmEnabled()) {
|
|
4208
|
+
debugLibvexDm(
|
|
4209
|
+
"readMail subsequent: first inbound used DH-ratchet fallback",
|
|
4210
|
+
{
|
|
4211
|
+
mailID: mail.mailID,
|
|
4212
|
+
thisDevice:
|
|
4213
|
+
this.getDevice().deviceID,
|
|
4214
|
+
},
|
|
4215
|
+
);
|
|
4216
|
+
}
|
|
4217
|
+
candidateSession = ratchetedCandidate;
|
|
4218
|
+
messageKey = ratchetedMessageKey;
|
|
4219
|
+
HMAC = ratchetedHMAC;
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4087
4222
|
|
|
4088
4223
|
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
4224
|
+
const failureCount =
|
|
4225
|
+
this.registerDecryptFailure(mail);
|
|
4226
|
+
if (libvexDebugDmEnabled()) {
|
|
4227
|
+
debugLibvexDm(
|
|
4228
|
+
"readMail subsequent: abort (HMAC mismatch)",
|
|
4229
|
+
{
|
|
4230
|
+
attempts: failureCount,
|
|
4231
|
+
mailID: mail.mailID,
|
|
4232
|
+
sender: mail.sender,
|
|
4233
|
+
thisDevice: this.getDevice().deviceID,
|
|
4234
|
+
},
|
|
4235
|
+
);
|
|
4236
|
+
}
|
|
4089
4237
|
healSession();
|
|
4238
|
+
if (failureCount === 1) {
|
|
4239
|
+
this.emitter.emit("retryRequest", {
|
|
4240
|
+
mailID: mail.mailID,
|
|
4241
|
+
source: "decrypt_failure",
|
|
4242
|
+
});
|
|
4243
|
+
}
|
|
4244
|
+
this.acknowledgeRepeatedDecryptFailure(
|
|
4245
|
+
mail,
|
|
4246
|
+
failureCount,
|
|
4247
|
+
);
|
|
4090
4248
|
return;
|
|
4091
4249
|
}
|
|
4092
4250
|
|
|
4251
|
+
session = candidateSession;
|
|
4252
|
+
|
|
4093
4253
|
const decrypted = await xSecretboxOpenAsync(
|
|
4094
4254
|
new Uint8Array(mail.cipher),
|
|
4095
4255
|
new Uint8Array(mail.nonce),
|
|
@@ -4182,11 +4342,19 @@ export class Client {
|
|
|
4182
4342
|
] = session;
|
|
4183
4343
|
this.acknowledgeInboundMail(mail);
|
|
4184
4344
|
} else {
|
|
4345
|
+
const failureCount =
|
|
4346
|
+
this.registerDecryptFailure(mail);
|
|
4185
4347
|
healSession();
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4348
|
+
if (failureCount === 1) {
|
|
4349
|
+
this.emitter.emit("retryRequest", {
|
|
4350
|
+
mailID: mail.mailID,
|
|
4351
|
+
source: "decrypt_failure",
|
|
4352
|
+
});
|
|
4353
|
+
}
|
|
4354
|
+
this.acknowledgeRepeatedDecryptFailure(
|
|
4355
|
+
mail,
|
|
4356
|
+
failureCount,
|
|
4357
|
+
);
|
|
4190
4358
|
}
|
|
4191
4359
|
break;
|
|
4192
4360
|
}
|
|
@@ -4206,6 +4374,12 @@ export class Client {
|
|
|
4206
4374
|
return decodeAxios(PermissionCodec, res.data);
|
|
4207
4375
|
}
|
|
4208
4376
|
|
|
4377
|
+
private registerDecryptFailure(mail: MailWS): number {
|
|
4378
|
+
const count = (this.decryptFailureCounts.get(mail.mailID) ?? 0) + 1;
|
|
4379
|
+
this.decryptFailureCounts.set(mail.mailID, count);
|
|
4380
|
+
return count;
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4209
4383
|
private async registerDevice(): Promise<DeviceRegistrationResult | null> {
|
|
4210
4384
|
while (!this.xKeyRing) {
|
|
4211
4385
|
await sleep(100);
|
|
@@ -4381,10 +4555,12 @@ export class Client {
|
|
|
4381
4555
|
if (newDevice && "deviceID" in newDevice) {
|
|
4382
4556
|
device = newDevice;
|
|
4383
4557
|
} else if (newDevice && "status" in newDevice) {
|
|
4384
|
-
throw new
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4558
|
+
throw new DeviceApprovalRequiredError({
|
|
4559
|
+
challenge: newDevice.challenge,
|
|
4560
|
+
expiresAt: newDevice.expiresAt,
|
|
4561
|
+
requestID: newDevice.requestID,
|
|
4562
|
+
userID: newDevice.userID ?? null,
|
|
4563
|
+
});
|
|
4388
4564
|
} else {
|
|
4389
4565
|
throw new Error("Error registering device.");
|
|
4390
4566
|
}
|
|
@@ -4488,17 +4664,32 @@ export class Client {
|
|
|
4488
4664
|
);
|
|
4489
4665
|
const msgBytes = XUtils.decodeUTF8(payload);
|
|
4490
4666
|
const myUserID = this.getUser().userID;
|
|
4491
|
-
// Fan-out only to *other* server members. The current account's other
|
|
4492
|
-
// devices receive the same group mail via `forward()` on the outgoing
|
|
4493
|
-
// `message` event (see `onInternalMessage`). Including our own devices
|
|
4494
|
-
// here races X3DH/ephemeral state and often fails silently — which
|
|
4495
|
-
// matched reports of flaky early group messages and missing delivery
|
|
4496
|
-
// while DMs (which never self-target) behaved better.
|
|
4497
4667
|
const peerUserIDs = [...new Set(userList.map((u) => u.userID))].filter(
|
|
4498
4668
|
(id) => id !== myUserID,
|
|
4499
4669
|
);
|
|
4670
|
+
const targetDevices = new Map<string, Device>();
|
|
4500
4671
|
|
|
4501
|
-
if (peerUserIDs.length
|
|
4672
|
+
if (peerUserIDs.length > 0) {
|
|
4673
|
+
const peerDevices = await this.getMultiUserDeviceList(peerUserIDs);
|
|
4674
|
+
if (peerDevices.length === 0) {
|
|
4675
|
+
throw new Error(
|
|
4676
|
+
"No devices registered for other channel members — cannot send group message.",
|
|
4677
|
+
);
|
|
4678
|
+
}
|
|
4679
|
+
for (const device of peerDevices) {
|
|
4680
|
+
targetDevices.set(device.deviceID, device);
|
|
4681
|
+
}
|
|
4682
|
+
}
|
|
4683
|
+
|
|
4684
|
+
const ownDevices = await this.fetchUserDeviceListWithBackoff(
|
|
4685
|
+
myUserID,
|
|
4686
|
+
"own",
|
|
4687
|
+
);
|
|
4688
|
+
for (const device of ownDevices) {
|
|
4689
|
+
targetDevices.set(device.deviceID, device);
|
|
4690
|
+
}
|
|
4691
|
+
|
|
4692
|
+
if (targetDevices.size === 0) {
|
|
4502
4693
|
const dev = this.getDevice();
|
|
4503
4694
|
const nonce = xMakeNonce();
|
|
4504
4695
|
this.emitter.emit("message", {
|
|
@@ -4518,21 +4709,17 @@ export class Client {
|
|
|
4518
4709
|
return;
|
|
4519
4710
|
}
|
|
4520
4711
|
|
|
4521
|
-
const
|
|
4522
|
-
if (devices.length === 0) {
|
|
4523
|
-
throw new Error(
|
|
4524
|
-
"No devices registered for other channel members — cannot send group message.",
|
|
4525
|
-
);
|
|
4526
|
-
}
|
|
4527
|
-
|
|
4528
|
-
const stableDevices = [...devices].sort((a, b) =>
|
|
4712
|
+
const stableDevices = [...targetDevices.values()].sort((a, b) =>
|
|
4529
4713
|
a.deviceID.localeCompare(b.deviceID, "en"),
|
|
4530
4714
|
);
|
|
4531
4715
|
|
|
4532
4716
|
let failCount = 0;
|
|
4533
4717
|
let lastErr: unknown;
|
|
4534
4718
|
for (const device of stableDevices) {
|
|
4535
|
-
const ownerRecord =
|
|
4719
|
+
const ownerRecord =
|
|
4720
|
+
device.owner === myUserID
|
|
4721
|
+
? this.getUser()
|
|
4722
|
+
: this.userRecords[device.owner];
|
|
4536
4723
|
if (!ownerRecord) {
|
|
4537
4724
|
failCount += 1;
|
|
4538
4725
|
continue;
|
package/src/Storage.ts
CHANGED
|
@@ -64,6 +64,8 @@ export interface Storage extends EventEmitter {
|
|
|
64
64
|
getSessionByPublicKey: (
|
|
65
65
|
publicKey: Uint8Array,
|
|
66
66
|
) => Promise<null | SessionCrypto>;
|
|
67
|
+
/** Returns whether a message with this `mailID` already exists locally. */
|
|
68
|
+
hasMessage: (mailID: string) => Promise<boolean>;
|
|
67
69
|
/**
|
|
68
70
|
* Performs storage initialization (schema creation, migrations, warmup, etc.).
|
|
69
71
|
*
|
|
@@ -155,6 +155,10 @@ export class MemoryStorage extends EventEmitter implements Storage {
|
|
|
155
155
|
return Promise.resolve(this.sqlToCrypto(s));
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
hasMessage(mailID: string): Promise<boolean> {
|
|
159
|
+
return Promise.resolve(this.messages.some((m) => m.mailID === mailID));
|
|
160
|
+
}
|
|
161
|
+
|
|
158
162
|
init(): Promise<void> {
|
|
159
163
|
this.ready = true;
|
|
160
164
|
this.emit("ready");
|
|
@@ -215,6 +219,9 @@ export class MemoryStorage extends EventEmitter implements Storage {
|
|
|
215
219
|
}
|
|
216
220
|
|
|
217
221
|
async saveMessage(message: Message): Promise<void> {
|
|
222
|
+
if (this.messages.some((m) => m.mailID === message.mailID)) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
218
225
|
const copy = { ...message };
|
|
219
226
|
const fips = getCryptoProfile() === "fips";
|
|
220
227
|
const ct = fips
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -225,8 +225,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
225
225
|
};
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
// ── Sessions ─────────────────────────────────────────────────────────────
|
|
229
|
-
|
|
230
228
|
async getPreKeys(): Promise<null | PreKeysCrypto> {
|
|
231
229
|
await this.untilReady();
|
|
232
230
|
if (this.closing) {
|
|
@@ -250,6 +248,8 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
250
248
|
};
|
|
251
249
|
}
|
|
252
250
|
|
|
251
|
+
// ── Sessions ─────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
253
|
async getSessionByDeviceID(
|
|
254
254
|
deviceID: string,
|
|
255
255
|
): Promise<null | SessionCrypto> {
|
|
@@ -297,6 +297,19 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
297
297
|
return this.sqlToCrypto(await this.sessionRowToSQLAsync(sessionRow));
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
async hasMessage(mailID: string): Promise<boolean> {
|
|
301
|
+
await this.untilReady();
|
|
302
|
+
if (this.closing) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
const row = await this.db
|
|
306
|
+
.selectFrom("messages")
|
|
307
|
+
.select("mailID")
|
|
308
|
+
.where("mailID", "=", mailID)
|
|
309
|
+
.executeTakeFirst();
|
|
310
|
+
return row !== undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
300
313
|
async init(): Promise<void> {
|
|
301
314
|
if (this.ready || this.closing) {
|
|
302
315
|
return;
|
|
@@ -358,6 +371,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
358
371
|
.execute();
|
|
359
372
|
await this.ensureSessionRatchetColumns();
|
|
360
373
|
await this.ensureRetentionHintColumn();
|
|
374
|
+
await this.ensureMessageMailIdIndex();
|
|
361
375
|
|
|
362
376
|
await this.db.schema
|
|
363
377
|
.createTable("preKeys")
|
|
@@ -507,6 +521,18 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
507
521
|
if (this.isClosingNow()) {
|
|
508
522
|
return;
|
|
509
523
|
}
|
|
524
|
+
await this.untilReady();
|
|
525
|
+
|
|
526
|
+
// Fan-out to multiple devices reuses one `mailID` but each encrypt path
|
|
527
|
+
// uses a fresh nonce (table PK). Keep a single local row per logical mail.
|
|
528
|
+
const dupe = await this.db
|
|
529
|
+
.selectFrom("messages")
|
|
530
|
+
.select("nonce")
|
|
531
|
+
.where("mailID", "=", message.mailID)
|
|
532
|
+
.executeTakeFirst();
|
|
533
|
+
if (dupe !== undefined) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
510
536
|
|
|
511
537
|
// Encrypt plaintext with at-rest key before saving to disk
|
|
512
538
|
const fips = getCryptoProfile() === "fips";
|
|
@@ -747,6 +773,19 @@ export class SqliteStorage extends EventEmitter implements Storage {
|
|
|
747
773
|
};
|
|
748
774
|
}
|
|
749
775
|
|
|
776
|
+
/** Speeds up mailID existence checks for saveMessage deduplication. */
|
|
777
|
+
private async ensureMessageMailIdIndex(): Promise<void> {
|
|
778
|
+
try {
|
|
779
|
+
await sql
|
|
780
|
+
.raw(
|
|
781
|
+
"CREATE INDEX IF NOT EXISTS messages_mailID_idx ON messages(mailID)",
|
|
782
|
+
)
|
|
783
|
+
.execute(this.db);
|
|
784
|
+
} catch {
|
|
785
|
+
// Extremely defensive — `messages` always exists at this point.
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
750
789
|
private async ensureRetentionHintColumn(): Promise<void> {
|
|
751
790
|
try {
|
|
752
791
|
await sql
|