@vex-chat/libvex 6.4.1 → 6.5.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 +3 -0
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +150 -18
- 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 +1 -1
- package/src/Client.ts +202 -30
- 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) {
|
|
@@ -3650,6 +3719,23 @@ export class Client {
|
|
|
3650
3719
|
return;
|
|
3651
3720
|
}
|
|
3652
3721
|
|
|
3722
|
+
if (await this.database.hasMessage(mail.mailID)) {
|
|
3723
|
+
if (libvexDebugDmEnabled()) {
|
|
3724
|
+
try {
|
|
3725
|
+
debugLibvexDm("readMail: skip (stored mailID)", {
|
|
3726
|
+
mailID: mail.mailID,
|
|
3727
|
+
thisDevice: this.getDevice().deviceID,
|
|
3728
|
+
});
|
|
3729
|
+
} catch {
|
|
3730
|
+
debugLibvexDm("readMail: skip (stored mailID)", {
|
|
3731
|
+
mailID: mail.mailID,
|
|
3732
|
+
});
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
this.acknowledgeInboundMail(mail);
|
|
3736
|
+
return;
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3653
3739
|
if (this.manuallyClosing) {
|
|
3654
3740
|
if (libvexDebugDmEnabled()) {
|
|
3655
3741
|
debugLibvexDm("readMail: skip (manually closing)", {
|
|
@@ -4056,40 +4142,110 @@ export class Client {
|
|
|
4056
4142
|
return;
|
|
4057
4143
|
}
|
|
4058
4144
|
|
|
4145
|
+
const originalSession = cloneSessionCrypto(session);
|
|
4059
4146
|
const firstInboundFromSubsequent = !session.DHr;
|
|
4147
|
+
let candidateSession = cloneSessionCrypto(session);
|
|
4060
4148
|
if (firstInboundFromSubsequent) {
|
|
4061
|
-
|
|
4062
|
-
// First inbound after X3DH initial mail
|
|
4063
|
-
//
|
|
4064
|
-
//
|
|
4065
|
-
//
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4149
|
+
candidateSession.DHr = ratchetHeader.dhPub;
|
|
4150
|
+
// First inbound after X3DH initial mail can be either:
|
|
4151
|
+
// - peer's bootstrap send chain if they replied before seeing
|
|
4152
|
+
// one of our subsequent messages; or
|
|
4153
|
+
// - a real DH ratchet if they already received one from us.
|
|
4154
|
+
// Try bootstrap first for backwards compatibility, then fall
|
|
4155
|
+
// back to the DH-ratchet interpretation if HMAC disagrees.
|
|
4156
|
+
if (!candidateSession.CKr) {
|
|
4157
|
+
candidateSession.CKr = deriveBootstrapSendChain(
|
|
4158
|
+
candidateSession.RK,
|
|
4069
4159
|
);
|
|
4070
4160
|
}
|
|
4071
4161
|
} else if (
|
|
4072
|
-
hasRemoteDhChanged(
|
|
4162
|
+
hasRemoteDhChanged(
|
|
4163
|
+
candidateSession.DHr,
|
|
4164
|
+
ratchetHeader.dhPub,
|
|
4165
|
+
)
|
|
4073
4166
|
) {
|
|
4074
4167
|
await ratchetStepReceive(
|
|
4075
|
-
|
|
4168
|
+
candidateSession,
|
|
4076
4169
|
ratchetHeader.dhPub,
|
|
4077
4170
|
ratchetHeader.pn,
|
|
4078
4171
|
);
|
|
4079
4172
|
}
|
|
4080
4173
|
|
|
4081
|
-
|
|
4082
|
-
|
|
4174
|
+
let messageKey = takeReceiveMessageKey(
|
|
4175
|
+
candidateSession,
|
|
4083
4176
|
ratchetHeader.dhPub,
|
|
4084
4177
|
ratchetHeader.n,
|
|
4085
4178
|
);
|
|
4086
|
-
|
|
4179
|
+
let HMAC = xHMAC(mail, messageKey);
|
|
4180
|
+
|
|
4181
|
+
if (
|
|
4182
|
+
!XUtils.bytesEqual(HMAC, header) &&
|
|
4183
|
+
firstInboundFromSubsequent &&
|
|
4184
|
+
!originalSession.CKr
|
|
4185
|
+
) {
|
|
4186
|
+
const ratchetedCandidate =
|
|
4187
|
+
cloneSessionCrypto(originalSession);
|
|
4188
|
+
await ratchetStepReceive(
|
|
4189
|
+
ratchetedCandidate,
|
|
4190
|
+
ratchetHeader.dhPub,
|
|
4191
|
+
ratchetHeader.pn,
|
|
4192
|
+
);
|
|
4193
|
+
const ratchetedMessageKey = takeReceiveMessageKey(
|
|
4194
|
+
ratchetedCandidate,
|
|
4195
|
+
ratchetHeader.dhPub,
|
|
4196
|
+
ratchetHeader.n,
|
|
4197
|
+
);
|
|
4198
|
+
const ratchetedHMAC = xHMAC(
|
|
4199
|
+
mail,
|
|
4200
|
+
ratchetedMessageKey,
|
|
4201
|
+
);
|
|
4202
|
+
if (XUtils.bytesEqual(ratchetedHMAC, header)) {
|
|
4203
|
+
if (libvexDebugDmEnabled()) {
|
|
4204
|
+
debugLibvexDm(
|
|
4205
|
+
"readMail subsequent: first inbound used DH-ratchet fallback",
|
|
4206
|
+
{
|
|
4207
|
+
mailID: mail.mailID,
|
|
4208
|
+
thisDevice:
|
|
4209
|
+
this.getDevice().deviceID,
|
|
4210
|
+
},
|
|
4211
|
+
);
|
|
4212
|
+
}
|
|
4213
|
+
candidateSession = ratchetedCandidate;
|
|
4214
|
+
messageKey = ratchetedMessageKey;
|
|
4215
|
+
HMAC = ratchetedHMAC;
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4087
4218
|
|
|
4088
4219
|
if (!XUtils.bytesEqual(HMAC, header)) {
|
|
4220
|
+
const failureCount =
|
|
4221
|
+
this.registerDecryptFailure(mail);
|
|
4222
|
+
if (libvexDebugDmEnabled()) {
|
|
4223
|
+
debugLibvexDm(
|
|
4224
|
+
"readMail subsequent: abort (HMAC mismatch)",
|
|
4225
|
+
{
|
|
4226
|
+
attempts: failureCount,
|
|
4227
|
+
mailID: mail.mailID,
|
|
4228
|
+
sender: mail.sender,
|
|
4229
|
+
thisDevice: this.getDevice().deviceID,
|
|
4230
|
+
},
|
|
4231
|
+
);
|
|
4232
|
+
}
|
|
4089
4233
|
healSession();
|
|
4234
|
+
if (failureCount === 1) {
|
|
4235
|
+
this.emitter.emit("retryRequest", {
|
|
4236
|
+
mailID: mail.mailID,
|
|
4237
|
+
source: "decrypt_failure",
|
|
4238
|
+
});
|
|
4239
|
+
}
|
|
4240
|
+
this.acknowledgeRepeatedDecryptFailure(
|
|
4241
|
+
mail,
|
|
4242
|
+
failureCount,
|
|
4243
|
+
);
|
|
4090
4244
|
return;
|
|
4091
4245
|
}
|
|
4092
4246
|
|
|
4247
|
+
session = candidateSession;
|
|
4248
|
+
|
|
4093
4249
|
const decrypted = await xSecretboxOpenAsync(
|
|
4094
4250
|
new Uint8Array(mail.cipher),
|
|
4095
4251
|
new Uint8Array(mail.nonce),
|
|
@@ -4182,11 +4338,19 @@ export class Client {
|
|
|
4182
4338
|
] = session;
|
|
4183
4339
|
this.acknowledgeInboundMail(mail);
|
|
4184
4340
|
} else {
|
|
4341
|
+
const failureCount =
|
|
4342
|
+
this.registerDecryptFailure(mail);
|
|
4185
4343
|
healSession();
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4344
|
+
if (failureCount === 1) {
|
|
4345
|
+
this.emitter.emit("retryRequest", {
|
|
4346
|
+
mailID: mail.mailID,
|
|
4347
|
+
source: "decrypt_failure",
|
|
4348
|
+
});
|
|
4349
|
+
}
|
|
4350
|
+
this.acknowledgeRepeatedDecryptFailure(
|
|
4351
|
+
mail,
|
|
4352
|
+
failureCount,
|
|
4353
|
+
);
|
|
4190
4354
|
}
|
|
4191
4355
|
break;
|
|
4192
4356
|
}
|
|
@@ -4206,6 +4370,12 @@ export class Client {
|
|
|
4206
4370
|
return decodeAxios(PermissionCodec, res.data);
|
|
4207
4371
|
}
|
|
4208
4372
|
|
|
4373
|
+
private registerDecryptFailure(mail: MailWS): number {
|
|
4374
|
+
const count = (this.decryptFailureCounts.get(mail.mailID) ?? 0) + 1;
|
|
4375
|
+
this.decryptFailureCounts.set(mail.mailID, count);
|
|
4376
|
+
return count;
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4209
4379
|
private async registerDevice(): Promise<DeviceRegistrationResult | null> {
|
|
4210
4380
|
while (!this.xKeyRing) {
|
|
4211
4381
|
await sleep(100);
|
|
@@ -4381,10 +4551,12 @@ export class Client {
|
|
|
4381
4551
|
if (newDevice && "deviceID" in newDevice) {
|
|
4382
4552
|
device = newDevice;
|
|
4383
4553
|
} else if (newDevice && "status" in newDevice) {
|
|
4384
|
-
throw new
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4554
|
+
throw new DeviceApprovalRequiredError({
|
|
4555
|
+
challenge: newDevice.challenge,
|
|
4556
|
+
expiresAt: newDevice.expiresAt,
|
|
4557
|
+
requestID: newDevice.requestID,
|
|
4558
|
+
userID: newDevice.userID ?? null,
|
|
4559
|
+
});
|
|
4388
4560
|
} else {
|
|
4389
4561
|
throw new Error("Error registering device.");
|
|
4390
4562
|
}
|
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
|