@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/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 blocks for ~5s on mobile.
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
- await this.negotiateOTK();
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 familiars: User[] = [];
2903
-
2904
- for (const session of sessions) {
2905
- const [user, _err] = await this.fetchUser(session.userID);
2906
- if (user) {
2907
- familiars.push(user);
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
- session.DHr = ratchetHeader.dhPub;
4061
- // First inbound after X3DH initial mail has no prior DH ratchet.
4062
- // If this side has no receiving chain yet (initiator path),
4063
- // derive the bootstrap receive chain to match peer's first
4064
- // bootstrap send chain.
4065
- if (!session.CKr) {
4066
- session.CKr = deriveBootstrapSendChain(
4067
- session.RK,
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(session.DHr, ratchetHeader.dhPub)
4162
+ hasRemoteDhChanged(
4163
+ candidateSession.DHr,
4164
+ ratchetHeader.dhPub,
4165
+ )
4072
4166
  ) {
4073
4167
  await ratchetStepReceive(
4074
- session,
4168
+ candidateSession,
4075
4169
  ratchetHeader.dhPub,
4076
4170
  ratchetHeader.pn,
4077
4171
  );
4078
4172
  }
4079
4173
 
4080
- const messageKey = takeReceiveMessageKey(
4081
- session,
4174
+ let messageKey = takeReceiveMessageKey(
4175
+ candidateSession,
4082
4176
  ratchetHeader.dhPub,
4083
4177
  ratchetHeader.n,
4084
4178
  );
4085
- const HMAC = xHMAC(mail, messageKey);
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
- this.emitter.emit("retryRequest", {
4186
- mailID: mail.mailID,
4187
- source: "decrypt_failure",
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 Error(
4384
- "Device registration requires approval from an existing device. requestID=" +
4385
- newDevice.requestID,
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
@@ -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