@vex-chat/libvex 6.4.0 → 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 +161 -28
- 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 +215 -42
- 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();
|
|
@@ -1726,9 +1778,12 @@ export class Client {
|
|
|
1726
1778
|
|
|
1727
1779
|
this.initSocket();
|
|
1728
1780
|
// Yield the event loop so the WS open callback fires and sends the
|
|
1729
|
-
// auth message before OTK generation
|
|
1781
|
+
// auth message before OTK generation starts. OTK top-up is best-effort
|
|
1782
|
+
// and should not block app bootstrap/hydration.
|
|
1730
1783
|
await new Promise((r) => setTimeout(r, 0));
|
|
1731
|
-
|
|
1784
|
+
this.negotiateOTK().catch(() => {
|
|
1785
|
+
// Best-effort: lacking fresh OTKs should not fail login/boot.
|
|
1786
|
+
});
|
|
1732
1787
|
}
|
|
1733
1788
|
|
|
1734
1789
|
/**
|
|
@@ -2176,10 +2231,27 @@ export class Client {
|
|
|
2176
2231
|
}
|
|
2177
2232
|
|
|
2178
2233
|
private acknowledgeInboundMail(mail: MailWS): void {
|
|
2234
|
+
this.decryptFailureCounts.delete(mail.mailID);
|
|
2179
2235
|
this.seenMailIDs.add(mail.mailID);
|
|
2180
2236
|
this.sendReceipt(new Uint8Array(mail.nonce));
|
|
2181
2237
|
}
|
|
2182
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
|
+
|
|
2183
2255
|
private async approveDeviceRequest(requestID: string): Promise<Device> {
|
|
2184
2256
|
const req = await this.getDeviceRegistrationRequest(requestID);
|
|
2185
2257
|
if (!req) {
|
|
@@ -2612,6 +2684,7 @@ export class Client {
|
|
|
2612
2684
|
private async deletePermission(permissionID: string): Promise<void> {
|
|
2613
2685
|
await this.http.delete(this.getHost() + "/permission/" + permissionID);
|
|
2614
2686
|
}
|
|
2687
|
+
|
|
2615
2688
|
private async deleteServer(serverID: string): Promise<void> {
|
|
2616
2689
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2617
2690
|
}
|
|
@@ -2628,7 +2701,6 @@ export class Client {
|
|
|
2628
2701
|
}
|
|
2629
2702
|
return "";
|
|
2630
2703
|
}
|
|
2631
|
-
|
|
2632
2704
|
/**
|
|
2633
2705
|
* Gets a list of permissions for a server.
|
|
2634
2706
|
*
|
|
@@ -2899,16 +2971,14 @@ export class Client {
|
|
|
2899
2971
|
/* Retrieves the current list of users you have sessions with. */
|
|
2900
2972
|
private async getFamiliars(): Promise<User[]> {
|
|
2901
2973
|
const sessions = await this.database.getAllSessions();
|
|
2902
|
-
const
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
return familiars;
|
|
2974
|
+
const userIDs = [...new Set(sessions.map((session) => session.userID))];
|
|
2975
|
+
const familiarEntries = await Promise.all(
|
|
2976
|
+
userIDs.map(async (userID) => {
|
|
2977
|
+
const [user] = await this.fetchUser(userID);
|
|
2978
|
+
return user ?? null;
|
|
2979
|
+
}),
|
|
2980
|
+
);
|
|
2981
|
+
return familiarEntries.filter((user): user is User => user !== null);
|
|
2912
2982
|
}
|
|
2913
2983
|
|
|
2914
2984
|
private async getGroupHistory(channelID: string): Promise<Message[]> {
|
|
@@ -3139,8 +3209,6 @@ export class Client {
|
|
|
3139
3209
|
return decodeAxios(UserArrayCodec, res.data);
|
|
3140
3210
|
}
|
|
3141
3211
|
|
|
3142
|
-
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3143
|
-
|
|
3144
3212
|
private async handleNotify(msg: NotifyMsg) {
|
|
3145
3213
|
switch (msg.event) {
|
|
3146
3214
|
case "deviceRequest": {
|
|
@@ -3209,6 +3277,8 @@ export class Client {
|
|
|
3209
3277
|
this.emitter.emit("ready");
|
|
3210
3278
|
}
|
|
3211
3279
|
|
|
3280
|
+
// ── Passkeys ────────────────────────────────────────────────────────
|
|
3281
|
+
|
|
3212
3282
|
private initSocket() {
|
|
3213
3283
|
try {
|
|
3214
3284
|
if (!this.token) {
|
|
@@ -3649,6 +3719,23 @@ export class Client {
|
|
|
3649
3719
|
return;
|
|
3650
3720
|
}
|
|
3651
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
|
+
|
|
3652
3739
|
if (this.manuallyClosing) {
|
|
3653
3740
|
if (libvexDebugDmEnabled()) {
|
|
3654
3741
|
debugLibvexDm("readMail: skip (manually closing)", {
|
|
@@ -4055,40 +4142,110 @@ export class Client {
|
|
|
4055
4142
|
return;
|
|
4056
4143
|
}
|
|
4057
4144
|
|
|
4145
|
+
const originalSession = cloneSessionCrypto(session);
|
|
4058
4146
|
const firstInboundFromSubsequent = !session.DHr;
|
|
4147
|
+
let candidateSession = cloneSessionCrypto(session);
|
|
4059
4148
|
if (firstInboundFromSubsequent) {
|
|
4060
|
-
|
|
4061
|
-
// First inbound after X3DH initial mail
|
|
4062
|
-
//
|
|
4063
|
-
//
|
|
4064
|
-
//
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
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,
|
|
4068
4159
|
);
|
|
4069
4160
|
}
|
|
4070
4161
|
} else if (
|
|
4071
|
-
hasRemoteDhChanged(
|
|
4162
|
+
hasRemoteDhChanged(
|
|
4163
|
+
candidateSession.DHr,
|
|
4164
|
+
ratchetHeader.dhPub,
|
|
4165
|
+
)
|
|
4072
4166
|
) {
|
|
4073
4167
|
await ratchetStepReceive(
|
|
4074
|
-
|
|
4168
|
+
candidateSession,
|
|
4075
4169
|
ratchetHeader.dhPub,
|
|
4076
4170
|
ratchetHeader.pn,
|
|
4077
4171
|
);
|
|
4078
4172
|
}
|
|
4079
4173
|
|
|
4080
|
-
|
|
4081
|
-
|
|
4174
|
+
let messageKey = takeReceiveMessageKey(
|
|
4175
|
+
candidateSession,
|
|
4082
4176
|
ratchetHeader.dhPub,
|
|
4083
4177
|
ratchetHeader.n,
|
|
4084
4178
|
);
|
|
4085
|
-
|
|
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
|
+
}
|
|
4086
4218
|
|
|
4087
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
|
+
}
|
|
4088
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
|
+
);
|
|
4089
4244
|
return;
|
|
4090
4245
|
}
|
|
4091
4246
|
|
|
4247
|
+
session = candidateSession;
|
|
4248
|
+
|
|
4092
4249
|
const decrypted = await xSecretboxOpenAsync(
|
|
4093
4250
|
new Uint8Array(mail.cipher),
|
|
4094
4251
|
new Uint8Array(mail.nonce),
|
|
@@ -4181,11 +4338,19 @@ export class Client {
|
|
|
4181
4338
|
] = session;
|
|
4182
4339
|
this.acknowledgeInboundMail(mail);
|
|
4183
4340
|
} else {
|
|
4341
|
+
const failureCount =
|
|
4342
|
+
this.registerDecryptFailure(mail);
|
|
4184
4343
|
healSession();
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
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
|
+
);
|
|
4189
4354
|
}
|
|
4190
4355
|
break;
|
|
4191
4356
|
}
|
|
@@ -4205,6 +4370,12 @@ export class Client {
|
|
|
4205
4370
|
return decodeAxios(PermissionCodec, res.data);
|
|
4206
4371
|
}
|
|
4207
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
|
+
|
|
4208
4379
|
private async registerDevice(): Promise<DeviceRegistrationResult | null> {
|
|
4209
4380
|
while (!this.xKeyRing) {
|
|
4210
4381
|
await sleep(100);
|
|
@@ -4380,10 +4551,12 @@ export class Client {
|
|
|
4380
4551
|
if (newDevice && "deviceID" in newDevice) {
|
|
4381
4552
|
device = newDevice;
|
|
4382
4553
|
} else if (newDevice && "status" in newDevice) {
|
|
4383
|
-
throw new
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4554
|
+
throw new DeviceApprovalRequiredError({
|
|
4555
|
+
challenge: newDevice.challenge,
|
|
4556
|
+
expiresAt: newDevice.expiresAt,
|
|
4557
|
+
requestID: newDevice.requestID,
|
|
4558
|
+
userID: newDevice.userID ?? null,
|
|
4559
|
+
});
|
|
4387
4560
|
} else {
|
|
4388
4561
|
throw new Error("Error registering device.");
|
|
4389
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
|