@vex-chat/libvex 6.3.0 → 6.3.2

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
@@ -42,7 +42,9 @@ import type { AxiosInstance } from "axios";
42
42
 
43
43
  import {
44
44
  type CryptoProfile,
45
+ enterCryptoProfileScope,
45
46
  getCryptoProfile,
47
+ leaveCryptoProfileScope,
46
48
  setCryptoProfile,
47
49
  xBoxKeyPairAsync,
48
50
  xBoxKeyPairFromSecretAsync,
@@ -387,6 +389,14 @@ export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
387
389
  * @ignore
388
390
  */
389
391
  export interface Devices {
392
+ /**
393
+ * Deletes an unpublished enrollment before any owner notification
394
+ * (e.g. user picked "not my account").
395
+ */
396
+ abortPendingRegistration: (args: {
397
+ challenge: string;
398
+ requestID: string;
399
+ }) => Promise<void>;
390
400
  /** Approves a pending device registration request as the current device. */
391
401
  approveRequest: (requestID: string) => Promise<Device>;
392
402
  /** Deletes one of the account's devices (except the currently active one). */
@@ -414,6 +424,14 @@ export interface Devices {
414
424
  challenge: string;
415
425
  requestID: string;
416
426
  }) => Promise<null | PendingDeviceRequest>;
427
+ /**
428
+ * After the user confirms the pending enrollment is theirs, notifies
429
+ * their existing devices (same proof as poll).
430
+ */
431
+ publishPendingRegistration: (args: {
432
+ challenge: string;
433
+ requestID: string;
434
+ }) => Promise<void>;
417
435
  /** Registers the current key material as a new device. */
418
436
  register: () => Promise<DeviceRegistrationResult | null>;
419
437
  /** Rejects a pending device registration request as the current device. */
@@ -1003,12 +1021,16 @@ export class Client {
1003
1021
  * Device management methods.
1004
1022
  */
1005
1023
  public devices: Devices = {
1024
+ abortPendingRegistration:
1025
+ this.abortPendingDeviceRegistration.bind(this),
1006
1026
  approveRequest: this.approveDeviceRequest.bind(this),
1007
1027
  delete: this.deleteDevice.bind(this),
1008
1028
  getRequest: this.getDeviceRegistrationRequest.bind(this),
1009
1029
  list: this.listDevices.bind(this),
1010
1030
  listRequests: this.listDeviceRegistrationRequests.bind(this),
1011
1031
  pollPendingRegistration: this.pollPendingDeviceRegistration.bind(this),
1032
+ publishPendingRegistration:
1033
+ this.publishPendingDeviceRegistration.bind(this),
1012
1034
  register: this.registerDevice.bind(this),
1013
1035
  rejectRequest: this.rejectDeviceRequest.bind(this),
1014
1036
  retrieve: this.getDeviceByID.bind(this),
@@ -1320,6 +1342,8 @@ export class Client {
1320
1342
  private reading: boolean = false;
1321
1343
  private retentionPurgeDebounce: null | ReturnType<typeof setTimeout> = null;
1322
1344
  private readonly seenMailIDs: Set<string> = new Set();
1345
+ private readonly sessionHealBackoffUntil = new Map<string, number>();
1346
+ private readonly sessionHealInFlight = new Set<string>();
1323
1347
  private sessionRecords: Record<string, SessionCrypto> = {};
1324
1348
 
1325
1349
  // these are created from one set of sign keys
@@ -2124,6 +2148,23 @@ export class Client {
2124
2148
  return whoami;
2125
2149
  }
2126
2150
 
2151
+ private async abortPendingDeviceRegistration(args: {
2152
+ challenge: string;
2153
+ requestID: string;
2154
+ }): Promise<void> {
2155
+ const signed = await this.signPendingRegistrationChallenge(
2156
+ args.challenge,
2157
+ );
2158
+ await this.http.post(
2159
+ this.getHost() +
2160
+ "/user/devices/requests/" +
2161
+ args.requestID +
2162
+ "/abort",
2163
+ msgpack.encode({ signed }),
2164
+ { headers: { "Content-Type": "application/msgpack" } },
2165
+ );
2166
+ }
2167
+
2127
2168
  private acknowledgeInboundMail(mail: MailWS): void {
2128
2169
  this.seenMailIDs.add(mail.mailID);
2129
2170
  this.sendReceipt(new Uint8Array(mail.nonce));
@@ -2561,7 +2602,6 @@ export class Client {
2561
2602
  private async deletePermission(permissionID: string): Promise<void> {
2562
2603
  await this.http.delete(this.getHost() + "/permission/" + permissionID);
2563
2604
  }
2564
-
2565
2605
  private async deleteServer(serverID: string): Promise<void> {
2566
2606
  await this.http.delete(this.getHost() + "/server/" + serverID);
2567
2607
  }
@@ -2578,6 +2618,7 @@ export class Client {
2578
2618
  }
2579
2619
  return "";
2580
2620
  }
2621
+
2581
2622
  /**
2582
2623
  * Gets a list of permissions for a server.
2583
2624
  *
@@ -2760,7 +2801,7 @@ export class Client {
2760
2801
  continue;
2761
2802
  }
2762
2803
  try {
2763
- await this.sendMail(
2804
+ await this.sendMailWithRecovery(
2764
2805
  device,
2765
2806
  this.getUser(),
2766
2807
  msgBytes,
@@ -3078,6 +3119,8 @@ export class Client {
3078
3119
  return decodeAxios(UserArrayCodec, res.data);
3079
3120
  }
3080
3121
 
3122
+ // ── Passkeys ────────────────────────────────────────────────────────
3123
+
3081
3124
  private async handleNotify(msg: NotifyMsg) {
3082
3125
  switch (msg.event) {
3083
3126
  case "deviceRequest": {
@@ -3146,8 +3189,6 @@ export class Client {
3146
3189
  this.emitter.emit("ready");
3147
3190
  }
3148
3191
 
3149
- // ── Passkeys ────────────────────────────────────────────────────────
3150
-
3151
3192
  private initSocket() {
3152
3193
  try {
3153
3194
  if (!this.token) {
@@ -3431,21 +3472,12 @@ export class Client {
3431
3472
  );
3432
3473
  }
3433
3474
 
3434
- /**
3435
- * Polls the public unauthenticated request status endpoint as the
3436
- * requesting device. Signs the server-issued challenge with the local
3437
- * private signing key so the server can verify ownership of the pending
3438
- * request without us needing a user token.
3439
- */
3440
3475
  private async pollPendingDeviceRegistration(args: {
3441
3476
  challenge: string;
3442
3477
  requestID: string;
3443
3478
  }): Promise<null | PendingDeviceRequest> {
3444
- const signed = XUtils.encodeHex(
3445
- await xSignAsync(
3446
- XUtils.decodeHex(args.challenge),
3447
- this.signKeys.secretKey,
3448
- ),
3479
+ const signed = await this.signPendingRegistrationChallenge(
3480
+ args.challenge,
3449
3481
  );
3450
3482
  try {
3451
3483
  const response = await this.http.post(
@@ -3555,6 +3587,23 @@ export class Client {
3555
3587
  }
3556
3588
  }
3557
3589
 
3590
+ private async publishPendingDeviceRegistration(args: {
3591
+ challenge: string;
3592
+ requestID: string;
3593
+ }): Promise<void> {
3594
+ const signed = await this.signPendingRegistrationChallenge(
3595
+ args.challenge,
3596
+ );
3597
+ await this.http.post(
3598
+ this.getHost() +
3599
+ "/user/devices/requests/" +
3600
+ args.requestID +
3601
+ "/publish",
3602
+ msgpack.encode({ signed }),
3603
+ { headers: { "Content-Type": "application/msgpack" } },
3604
+ );
3605
+ }
3606
+
3558
3607
  private async purgeHistory(): Promise<void> {
3559
3608
  await this.database.purgeHistory();
3560
3609
  }
@@ -3598,23 +3647,50 @@ export class Client {
3598
3647
 
3599
3648
  try {
3600
3649
  await this.runWithThisCryptoProfile(async () => {
3601
- const healSession = async () => {
3650
+ const healSession = () => {
3602
3651
  if (this.manuallyClosing || !this.xKeyRing) {
3603
3652
  return;
3604
3653
  }
3605
- const deviceEntry = await this.getDeviceByID(mail.sender);
3606
- const [user, _err] = await this.fetchUser(mail.authorID);
3607
- if (deviceEntry && user) {
3608
- void this.createSession(
3609
- deviceEntry,
3610
- user,
3611
- new Uint8Array(),
3612
- mail.group,
3613
- uuid.v4(),
3614
- false,
3615
- true,
3616
- );
3654
+ const senderDeviceID = mail.sender;
3655
+ const now = Date.now();
3656
+ const blockedUntil =
3657
+ this.sessionHealBackoffUntil.get(senderDeviceID) ?? 0;
3658
+ if (now < blockedUntil) {
3659
+ return;
3660
+ }
3661
+ if (this.sessionHealInFlight.has(senderDeviceID)) {
3662
+ return;
3617
3663
  }
3664
+ this.sessionHealInFlight.add(senderDeviceID);
3665
+ void (async () => {
3666
+ try {
3667
+ const deviceEntry =
3668
+ await this.getDeviceByID(senderDeviceID);
3669
+ const [user, _err] = await this.fetchUser(
3670
+ mail.authorID,
3671
+ );
3672
+ if (deviceEntry && user) {
3673
+ await this.createSession(
3674
+ deviceEntry,
3675
+ user,
3676
+ new Uint8Array(),
3677
+ mail.group,
3678
+ uuid.v4(),
3679
+ false,
3680
+ true,
3681
+ );
3682
+ }
3683
+ } finally {
3684
+ // Avoid hammering /keyBundle when a bad/corrupt mail item
3685
+ // triggers repeated decrypt failures for the same sender.
3686
+ // Use a conservative backoff to cap load during auth churn.
3687
+ this.sessionHealBackoffUntil.set(
3688
+ senderDeviceID,
3689
+ Date.now() + 30_000,
3690
+ );
3691
+ this.sessionHealInFlight.delete(senderDeviceID);
3692
+ }
3693
+ })();
3618
3694
  };
3619
3695
 
3620
3696
  switch (mail.mailType) {
@@ -3955,7 +4031,7 @@ export class Client {
3955
4031
  }
3956
4032
 
3957
4033
  if (!session) {
3958
- void healSession();
4034
+ healSession();
3959
4035
  return;
3960
4036
  }
3961
4037
 
@@ -3989,7 +4065,7 @@ export class Client {
3989
4065
  const HMAC = xHMAC(mail, messageKey);
3990
4066
 
3991
4067
  if (!XUtils.bytesEqual(HMAC, header)) {
3992
- void healSession();
4068
+ healSession();
3993
4069
  return;
3994
4070
  }
3995
4071
 
@@ -4085,7 +4161,7 @@ export class Client {
4085
4161
  ] = session;
4086
4162
  this.acknowledgeInboundMail(mail);
4087
4163
  } else {
4088
- void healSession();
4164
+ healSession();
4089
4165
  this.emitter.emit("retryRequest", {
4090
4166
  mailID: mail.mailID,
4091
4167
  source: "decrypt_failure",
@@ -4320,15 +4396,11 @@ export class Client {
4320
4396
  private async runWithThisCryptoProfile<T>(
4321
4397
  fn: () => Promise<T>,
4322
4398
  ): Promise<T> {
4323
- const prev = getCryptoProfile();
4324
- if (prev === this.cryptoProfile) {
4325
- return await fn();
4326
- }
4327
- setCryptoProfile(this.cryptoProfile);
4399
+ enterCryptoProfileScope(this.cryptoProfile);
4328
4400
  try {
4329
4401
  return await fn();
4330
4402
  } finally {
4331
- setCryptoProfile(prev);
4403
+ leaveCryptoProfileScope();
4332
4404
  }
4333
4405
  }
4334
4406
 
@@ -4350,10 +4422,14 @@ export class Client {
4350
4422
  let elapsed = 0;
4351
4423
  let backoff = 50;
4352
4424
  while (this.socket.readyState !== 1) {
4425
+ if (this.isManualCloseInFlight()) {
4426
+ throw new WebSocketNotOpenError(this.socket.readyState);
4427
+ }
4428
+ if (this.socket.readyState === 2 || this.socket.readyState === 3) {
4429
+ throw new WebSocketNotOpenError(this.socket.readyState);
4430
+ }
4353
4431
  if (elapsed >= maxWaitMs) {
4354
- throw new Error(
4355
- "WebSocket did not reach OPEN state within 30 seconds.",
4356
- );
4432
+ throw new WebSocketNotOpenError(this.socket.readyState);
4357
4433
  }
4358
4434
  await sleep(backoff);
4359
4435
  elapsed += backoff;
@@ -4441,7 +4517,7 @@ export class Client {
4441
4517
  continue;
4442
4518
  }
4443
4519
  try {
4444
- await this.sendMail(
4520
+ await this.sendMailWithRecovery(
4445
4521
  device,
4446
4522
  ownerRecord,
4447
4523
  msgBytes,
@@ -4661,6 +4737,32 @@ export class Client {
4661
4737
  }
4662
4738
  }
4663
4739
 
4740
+ private async sendMailWithRecovery(
4741
+ device: Device,
4742
+ user: User,
4743
+ msg: Uint8Array,
4744
+ group: null | Uint8Array,
4745
+ mailID: null | string,
4746
+ forward: boolean,
4747
+ ): Promise<void> {
4748
+ try {
4749
+ await this.sendMail(device, user, msg, group, mailID, forward);
4750
+ } catch (err: unknown) {
4751
+ if (!this.shouldRetryDeliveryWithFreshSession(err)) {
4752
+ throw err;
4753
+ }
4754
+ await this.sendMail(
4755
+ device,
4756
+ user,
4757
+ msg,
4758
+ group,
4759
+ mailID,
4760
+ forward,
4761
+ true,
4762
+ );
4763
+ }
4764
+ }
4765
+
4664
4766
  private async sendMessage(
4665
4767
  userID: string,
4666
4768
  message: string,
@@ -4739,7 +4841,7 @@ export class Client {
4739
4841
  recipientDevice: device.deviceID,
4740
4842
  });
4741
4843
  }
4742
- await this.sendMail(
4844
+ await this.sendMailWithRecovery(
4743
4845
  device,
4744
4846
  userEntry,
4745
4847
  XUtils.decodeUTF8(payload),
@@ -4805,6 +4907,44 @@ export class Client {
4805
4907
 
4806
4908
  private setUser(user: User): void {
4807
4909
  this.user = user;
4910
+ // Fresh identity / token: drop stale 404 negative-cache entries so a
4911
+ // prior transient miss (or wrong host) cannot block DM sends for 30m.
4912
+ this.notFoundUsers.clear();
4913
+ }
4914
+
4915
+ private shouldRetryDeliveryWithFreshSession(err: unknown): boolean {
4916
+ if (err instanceof WebSocketNotOpenError) {
4917
+ return true;
4918
+ }
4919
+ const message =
4920
+ err instanceof Error
4921
+ ? err.message.toLowerCase()
4922
+ : String(err).toLowerCase();
4923
+ return (
4924
+ message.includes("mail delivery failed") ||
4925
+ message.includes("not authenticated") ||
4926
+ message.includes("unauthorized") ||
4927
+ message.includes("websocket") ||
4928
+ message.includes("network") ||
4929
+ message.includes("timed out")
4930
+ );
4931
+ }
4932
+
4933
+ /**
4934
+ * Polls the public unauthenticated request status endpoint as the
4935
+ * requesting device. Signs the server-issued challenge with the local
4936
+ * private signing key so the server can verify ownership of the pending
4937
+ * request without us needing a user token.
4938
+ */
4939
+ private async signPendingRegistrationChallenge(
4940
+ challengeHex: string,
4941
+ ): Promise<string> {
4942
+ return XUtils.encodeHex(
4943
+ await xSignAsync(
4944
+ XUtils.decodeHex(challengeHex),
4945
+ this.signKeys.secretKey,
4946
+ ),
4947
+ );
4808
4948
  }
4809
4949
 
4810
4950
  private async submitOTK(amount: number) {
package/src/Storage.ts CHANGED
@@ -93,7 +93,8 @@ export interface Storage extends EventEmitter {
93
93
  on(event: "error", callback: (error: Error) => void): this;
94
94
  /**
95
95
  * Deletes local messages older than the effective per-row retention:
96
- * `min(30 days, clientMaxRetentionDays, retentionHintDays ?? 30)`.
96
+ * `min(30 days, clientMaxRetentionDays, effectiveMessageRetentionHintDays(row))`
97
+ * (invalid or non-positive stored hints are treated like 30).
97
98
  */
98
99
  pruneExpiredLocalMessages: (
99
100
  clientMaxRetentionDays: number,
@@ -24,13 +24,16 @@ import {
24
24
  XUtils,
25
25
  } from "@vex-chat/crypto";
26
26
 
27
+ import { EventEmitter } from "eventemitter3";
28
+
29
+ import { effectiveMessageRetentionHintDays } from "../../retention.js";
30
+
27
31
  /**
28
32
  * Minimal in-memory Storage for browser/RN platform tests.
29
33
  *
30
34
  * Uses eventemitter3 (browser-safe) instead of Node's events module.
31
35
  * No persistence — just enough for the register/login/connect/DM test flow.
32
36
  */
33
- import { EventEmitter } from "eventemitter3";
34
37
 
35
38
  export class MemoryStorage extends EventEmitter implements Storage {
36
39
  public ready = false;
@@ -178,7 +181,9 @@ export class MemoryStorage extends EventEmitter implements Storage {
178
181
  const now = Date.now();
179
182
  const msPerDay = 86_400_000;
180
183
  this.messages = this.messages.filter((m) => {
181
- const hintDays = m.retentionHintDays ?? 30;
184
+ const hintDays = effectiveMessageRetentionHintDays(
185
+ m.retentionHintDays,
186
+ );
182
187
  const maxDays = Math.min(30, cap, hintDays);
183
188
  const ts = new Date(m.timestamp).getTime();
184
189
  if (!Number.isFinite(ts)) {
@@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest";
7
7
 
8
8
  import {
9
9
  clampLocalMessageRetentionDays,
10
+ effectiveMessageRetentionHintDays,
10
11
  formatVexRetentionEnvelope,
11
12
  MAX_LOCAL_MESSAGE_RETENTION_DAYS,
12
13
  stripVexRetentionEnvelope,
@@ -36,4 +37,14 @@ describe("retention", () => {
36
37
  it("MAX matches server contract constant name in docs", () => {
37
38
  expect(MAX_LOCAL_MESSAGE_RETENTION_DAYS).toBe(30);
38
39
  });
40
+
41
+ it("effectiveMessageRetentionHintDays treats 0 and invalid as 30-day default", () => {
42
+ expect(effectiveMessageRetentionHintDays(undefined)).toBe(30);
43
+ expect(effectiveMessageRetentionHintDays(null)).toBe(30);
44
+ expect(effectiveMessageRetentionHintDays(0)).toBe(30);
45
+ expect(effectiveMessageRetentionHintDays(-3)).toBe(30);
46
+ expect(effectiveMessageRetentionHintDays(Number.NaN)).toBe(30);
47
+ expect(effectiveMessageRetentionHintDays(7)).toBe(7);
48
+ expect(effectiveMessageRetentionHintDays(45)).toBe(30);
49
+ });
39
50
  });
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export type {
40
40
  export { createCodec, msgpack } from "./codec.js";
41
41
  export {
42
42
  clampLocalMessageRetentionDays,
43
+ effectiveMessageRetentionHintDays,
43
44
  MAX_LOCAL_MESSAGE_RETENTION_DAYS,
44
45
  } from "./retention.js";
45
46
  export type { Storage } from "./Storage.js";
package/src/retention.ts CHANGED
@@ -24,6 +24,23 @@ export function clampLocalMessageRetentionDays(
24
24
  return Math.min(MAX_LOCAL_MESSAGE_RETENTION_DAYS, Math.max(1, n));
25
25
  }
26
26
 
27
+ /**
28
+ * Normalizes a per-message `retentionHintDays` value read from storage.
29
+ * Non-finite, missing, or non-positive values behave like "no hint" (30 days)
30
+ * so a corrupt `0` row cannot wipe the entire local history on prune.
31
+ */
32
+ export function effectiveMessageRetentionHintDays(
33
+ stored: null | number | undefined,
34
+ ): number {
35
+ if (stored == null || !Number.isFinite(stored) || stored < 1) {
36
+ return MAX_LOCAL_MESSAGE_RETENTION_DAYS;
37
+ }
38
+ return Math.min(
39
+ MAX_LOCAL_MESSAGE_RETENTION_DAYS,
40
+ Math.max(1, Math.round(stored)),
41
+ );
42
+ }
43
+
27
44
  const RETENTION_PREFIX = /^vex-retention:([1-9]|[12]\d|30)\n/;
28
45
 
29
46
  /**
@@ -28,6 +28,13 @@ import type { Device, PreKeysSQL, SessionSQL } from "@vex-chat/types";
28
28
  *
29
29
  * This replaces three separate storage classes (Storage.ts, TauriStorage,
30
30
  * ExpoStorage) with a single implementation.
31
+ *
32
+ * **One database file today** holds both `sessions` (Double Ratchet /
33
+ * X3DH state — required to decrypt *new* traffic) and `messages` (history).
34
+ * If the file is lost or corrupted you lose both; restoring from backup
35
+ * re-seeds device keys but cannot reconstruct dropped ratchet chains from
36
+ * the server alone. A future split could park `sessions` + OTKs in a
37
+ * smaller “crypto state” store separate from bulk message history.
31
38
  */
32
39
  import {
33
40
  getCryptoProfile,
@@ -44,6 +51,7 @@ import {
44
51
  import { EventEmitter } from "eventemitter3";
45
52
  import { type Kysely, sql } from "kysely";
46
53
 
54
+ import { effectiveMessageRetentionHintDays } from "../retention.js";
47
55
  import { parseSkippedKeysStrict } from "../utils/ratchet.js";
48
56
 
49
57
  export class SqliteStorage extends EventEmitter implements Storage {
@@ -436,7 +444,9 @@ export class SqliteStorage extends EventEmitter implements Storage {
436
444
  const msPerDay = 86_400_000;
437
445
  const toDelete: string[] = [];
438
446
  for (const r of rows) {
439
- const hintDays = r.retentionHintDays ?? 30;
447
+ const hintDays = effectiveMessageRetentionHintDays(
448
+ r.retentionHintDays,
449
+ );
440
450
  const maxDays = Math.min(30, cap, hintDays);
441
451
  const ts = new Date(r.timestamp).getTime();
442
452
  if (!Number.isFinite(ts)) {
@@ -668,6 +678,14 @@ export class SqliteStorage extends EventEmitter implements Storage {
668
678
  ): Promise<Message[]> {
669
679
  const fips = getCryptoProfile() === "fips";
670
680
  const out: Message[] = [];
681
+ let processed = 0;
682
+ /** Yield so RN / web UIs can paint between at-rest decrypt blocks. */
683
+ const yieldToHost = (): Promise<void> =>
684
+ new Promise((resolve) => {
685
+ setTimeout(resolve, 0);
686
+ });
687
+ const yieldEvery = 28;
688
+
671
689
  for (const msg of messages) {
672
690
  const decryptedFlag = msg.decrypted !== 0;
673
691
  let plaintext = msg.message;
@@ -707,6 +725,11 @@ export class SqliteStorage extends EventEmitter implements Storage {
707
725
  rowMessage.retentionHintDays = msg.retentionHintDays;
708
726
  }
709
727
  out.push(rowMessage);
728
+
729
+ processed += 1;
730
+ if (processed % yieldEvery === 0) {
731
+ await yieldToHost();
732
+ }
710
733
  }
711
734
  return out;
712
735
  }