@vex-chat/libvex 6.5.2 → 6.5.4

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
@@ -109,6 +109,7 @@ import {
109
109
  takeReceiveMessageKey,
110
110
  takeSendMessageKey,
111
111
  } from "./utils/ratchet.js";
112
+ import { verifyKeyBundleSignatures } from "./utils/verifyKeyBundle.js";
112
113
 
113
114
  /**
114
115
  * Thrown by {@link Client.register} when the server determined the supplied
@@ -350,18 +351,6 @@ import { uuidToUint8 } from "./utils/uint8uuid.js";
350
351
 
351
352
  const _protocolMsgRegex = /��\w+:\w+��/g;
352
353
 
353
- /**
354
- * Permission is a permission to a resource.
355
- *
356
- * Common fields:
357
- * - `permissionID`: unique permission row ID
358
- * - `userID`: user receiving this grant
359
- * - `resourceID`: target server/channel/etc.
360
- * - `resourceType`: type string for the resource
361
- * - `powerLevel`: authorization level
362
- */
363
- export type { Permission } from "@vex-chat/types";
364
-
365
354
  /**
366
355
  * @ignore
367
356
  */
@@ -378,27 +367,6 @@ export interface Channels {
378
367
  userList: (channelID: string) => Promise<User[]>;
379
368
  }
380
369
 
381
- /**
382
- * Device record associated with a user account.
383
- *
384
- * Common fields:
385
- * - `deviceID`: unique device identifier
386
- * - `owner`: owning user ID
387
- * - `signKey`: signing public key
388
- * - `name`: user-facing device name
389
- * - `lastLogin`: last login timestamp string
390
- * - `deleted`: soft-delete flag
391
- */
392
- export type { Device } from "@vex-chat/types";
393
-
394
- /**
395
- * Public passkey record returned by `client.passkeys.list()` and
396
- * `client.passkeys.finishRegistration()`. Server-private fields
397
- * (credential ID, public key, COSE algorithm, signature counter) are
398
- * never exposed.
399
- */
400
- export type { Passkey } from "@vex-chat/types";
401
-
402
370
  /**
403
371
  * ClientOptions are the options you can pass into the client.
404
372
  */
@@ -436,8 +404,41 @@ export interface ClientOptions {
436
404
  unsafeHttp?: boolean;
437
405
  }
438
406
 
407
+ /**
408
+ * Permission is a permission to a resource.
409
+ *
410
+ * Common fields:
411
+ * - `permissionID`: unique permission row ID
412
+ * - `userID`: user receiving this grant
413
+ * - `resourceID`: target server/channel/etc.
414
+ * - `resourceType`: type string for the resource
415
+ * - `powerLevel`: authorization level
416
+ */
417
+ export type { Permission } from "@vex-chat/types";
418
+
439
419
  export type DeviceRegistrationResult = Device | PendingDeviceRegistration;
440
420
 
421
+ /**
422
+ * Device record associated with a user account.
423
+ *
424
+ * Common fields:
425
+ * - `deviceID`: unique device identifier
426
+ * - `owner`: owning user ID
427
+ * - `signKey`: signing public key
428
+ * - `name`: user-facing device name
429
+ * - `lastLogin`: last login timestamp string
430
+ * - `deleted`: soft-delete flag
431
+ */
432
+ export type { Device } from "@vex-chat/types";
433
+
434
+ /**
435
+ * Public passkey record returned by `client.passkeys.list()` and
436
+ * `client.passkeys.finishRegistration()`. Server-private fields
437
+ * (credential ID, public key, COSE algorithm, signature counter) are
438
+ * never exposed.
439
+ */
440
+ export type { Passkey } from "@vex-chat/types";
441
+
441
442
  /**
442
443
  * @ignore
443
444
  */
@@ -556,6 +557,29 @@ export interface Files {
556
557
  retrieve: (fileID: string, key: string) => Promise<FileResponse | null>;
557
558
  }
558
559
 
560
+ /**
561
+ * @ignore
562
+ */
563
+ export interface Invites {
564
+ /** Creates an invite for a server and duration. */
565
+ create: (serverID: string, duration: string) => Promise<Invite>;
566
+ /** Redeems an invite and returns the created permission grant. */
567
+ redeem: (inviteID: string) => Promise<Permission>;
568
+ /** Lists active invites for a server. */
569
+ retrieve: (serverID: string) => Promise<Invite[]>;
570
+ }
571
+
572
+ /**
573
+ * Keys are a pair of ed25519 public and private keys,
574
+ * encoded as hex strings.
575
+ */
576
+ export interface Keys {
577
+ /** Secret Ed25519 key as hex. Store securely. */
578
+ private: string;
579
+ /** Public Ed25519 key as hex. */
580
+ public: string;
581
+ }
582
+
559
583
  /**
560
584
  * Channel is a chat channel on a server.
561
585
  *
@@ -581,29 +605,6 @@ export type { Server } from "@vex-chat/types";
581
605
  */
582
606
  export type { ServerChannelBootstrap } from "@vex-chat/types";
583
607
 
584
- /**
585
- * @ignore
586
- */
587
- export interface Invites {
588
- /** Creates an invite for a server and duration. */
589
- create: (serverID: string, duration: string) => Promise<Invite>;
590
- /** Redeems an invite and returns the created permission grant. */
591
- redeem: (inviteID: string) => Promise<Permission>;
592
- /** Lists active invites for a server. */
593
- retrieve: (serverID: string) => Promise<Invite[]>;
594
- }
595
-
596
- /**
597
- * Keys are a pair of ed25519 public and private keys,
598
- * encoded as hex strings.
599
- */
600
- export interface Keys {
601
- /** Secret Ed25519 key as hex. Store securely. */
602
- private: string;
603
- /** Public Ed25519 key as hex. */
604
- public: string;
605
- }
606
-
607
608
  /**
608
609
  * @ignore
609
610
  */
@@ -753,6 +754,59 @@ export interface RetryRequest {
753
754
  source: "decrypt_failure" | "server_notify";
754
755
  }
755
756
 
757
+ function compareInboxEntries(
758
+ a: [Uint8Array, MailWS, string],
759
+ b: [Uint8Array, MailWS, string],
760
+ ): number {
761
+ const timeCmp = a[2].localeCompare(b[2]);
762
+ if (timeCmp !== 0) {
763
+ return timeCmp;
764
+ }
765
+
766
+ const aMail = a[1];
767
+ const bMail = b[1];
768
+ if (aMail.sender !== bMail.sender) {
769
+ return aMail.sender.localeCompare(bMail.sender, "en");
770
+ }
771
+
772
+ const typeCmp = aMail.mailType - bMail.mailType;
773
+ if (typeCmp !== 0) {
774
+ return typeCmp;
775
+ }
776
+
777
+ if (
778
+ aMail.mailType === MailType.subsequent &&
779
+ bMail.mailType === MailType.subsequent
780
+ ) {
781
+ const aHeader = tryDecodeRatchetHeader(aMail.extra);
782
+ const bHeader = tryDecodeRatchetHeader(bMail.extra);
783
+ if (aHeader && bHeader) {
784
+ const dhCmp = XUtils.encodeHex(aHeader.dhPub).localeCompare(
785
+ XUtils.encodeHex(bHeader.dhPub),
786
+ "en",
787
+ );
788
+ if (dhCmp !== 0) {
789
+ return dhCmp;
790
+ }
791
+ const pnCmp = aHeader.pn - bHeader.pn;
792
+ if (pnCmp !== 0) {
793
+ return pnCmp;
794
+ }
795
+ return aHeader.n - bHeader.n;
796
+ }
797
+ }
798
+
799
+ return aMail.nonce.toString().localeCompare(bMail.nonce.toString(), "en");
800
+ }
801
+
802
+ function tryDecodeRatchetHeader(extra: Uint8Array) {
803
+ try {
804
+ return decodeRatchetHeader(extra);
805
+ } catch {
806
+ return null;
807
+ }
808
+ }
809
+
756
810
  /** Zod schema matching the {@link Message} interface for forwarded-message decode. */
757
811
  const messageSchema: z.ZodType<Message> = z.object({
758
812
  authorID: z.string(),
@@ -2440,6 +2494,11 @@ export class Client {
2440
2494
 
2441
2495
  try {
2442
2496
  keyBundle = await this.retrieveKeyBundle(device.deviceID);
2497
+ await verifyKeyBundleSignatures(
2498
+ keyBundle,
2499
+ device,
2500
+ this.cryptoProfile,
2501
+ );
2443
2502
  } catch (e) {
2444
2503
  if (allowKeyBundleFailure) {
2445
2504
  return;
@@ -3001,7 +3060,7 @@ export class Client {
3001
3060
  const rawInbox = z
3002
3061
  .array(mailInboxEntry)
3003
3062
  .parse(msgpack.decode(mailBuffer));
3004
- const inbox = rawInbox.sort((a, b) => b[2].localeCompare(a[2]));
3063
+ const inbox = rawInbox.sort(compareInboxEntries);
3005
3064
 
3006
3065
  if (libvexDebugDmEnabled()) {
3007
3066
  const did = (() => {
@@ -3046,8 +3105,9 @@ export class Client {
3046
3105
  fetchErr,
3047
3106
  );
3048
3107
  }
3108
+ } finally {
3109
+ this.fetchingMail = false;
3049
3110
  }
3050
- this.fetchingMail = false;
3051
3111
  }
3052
3112
 
3053
3113
  private async getMessageHistory(userID: string): Promise<Message[]> {
@@ -3204,7 +3264,6 @@ export class Client {
3204
3264
  }
3205
3265
  case "mail":
3206
3266
  await this.getMail();
3207
- this.fetchingMail = false;
3208
3267
  break;
3209
3268
  case "permission":
3210
3269
  this.emitter.emit(
@@ -3232,6 +3291,27 @@ export class Client {
3232
3291
  }
3233
3292
  }
3234
3293
 
3294
+ private handleTerminalSocketState(reason: string): boolean {
3295
+ const { readyState } = this.socket;
3296
+ if (readyState !== 2 && readyState !== 3) {
3297
+ return false;
3298
+ }
3299
+ if (this.isManualCloseInFlight()) {
3300
+ return true;
3301
+ }
3302
+ if (this.pingInterval) {
3303
+ clearInterval(this.pingInterval);
3304
+ this.pingInterval = null;
3305
+ }
3306
+ debugLibvexDm("websocket-terminal-state", {
3307
+ readyState,
3308
+ reason,
3309
+ });
3310
+ this.emitter.emit("disconnect");
3311
+ this.scheduleReconnect();
3312
+ return true;
3313
+ }
3314
+
3235
3315
  // ── Passkeys ────────────────────────────────────────────────────────
3236
3316
 
3237
3317
  /**
@@ -3289,6 +3369,7 @@ export class Client {
3289
3369
  this.socket.send(new TextEncoder().encode(authMsg));
3290
3370
  } catch (err: unknown) {
3291
3371
  if (err instanceof WebSocketNotOpenError) {
3372
+ this.handleTerminalSocketState("auth-open");
3292
3373
  return;
3293
3374
  }
3294
3375
  throw err;
@@ -3522,6 +3603,9 @@ export class Client {
3522
3603
  }
3523
3604
 
3524
3605
  private ping() {
3606
+ if (this.handleTerminalSocketState("ping")) {
3607
+ return;
3608
+ }
3525
3609
  if (!this.isAlive) {
3526
3610
  // Previous ping went unanswered — the WebSocket is half-open
3527
3611
  // (e.g., the network stack silently dropped the flow without a
@@ -3540,6 +3624,7 @@ export class Client {
3540
3624
  } catch {
3541
3625
  // socket may already be CLOSING/CLOSED; ignore.
3542
3626
  }
3627
+ this.scheduleReconnect();
3543
3628
  return;
3544
3629
  }
3545
3630
  this.setAlive(false);
@@ -3641,7 +3726,6 @@ export class Client {
3641
3726
  try {
3642
3727
  await this.getMail();
3643
3728
  count++;
3644
- this.fetchingMail = false;
3645
3729
 
3646
3730
  if (count > 10) {
3647
3731
  void this.negotiateOTK();
@@ -4221,7 +4305,9 @@ export class Client {
4221
4305
  },
4222
4306
  );
4223
4307
  }
4224
- healSession();
4308
+ if (failureCount >= 2) {
4309
+ healSession();
4310
+ }
4225
4311
  if (failureCount === 1) {
4226
4312
  this.emitter.emit("retryRequest", {
4227
4313
  mailID: mail.mailID,
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { CryptoProfile, KeyPair } from "@vex-chat/crypto";
8
+ import type { Device, KeyBundle, KeyBundleEntry } from "@vex-chat/types";
9
+
10
+ import {
11
+ setCryptoProfile,
12
+ xBoxKeyPairAsync,
13
+ xConstants,
14
+ xEcdhKeyPairFromEcdsaKeyPairAsync,
15
+ xEncode,
16
+ xSignAsync,
17
+ xSignKeyPairAsync,
18
+ XUtils,
19
+ } from "@vex-chat/crypto";
20
+
21
+ import { afterEach, describe, expect, it } from "vitest";
22
+
23
+ import { fipsP256PreKeySignPayload } from "../utils/fipsMailExtra.js";
24
+ import { verifyKeyBundleSignatures } from "../utils/verifyKeyBundle.js";
25
+
26
+ describe.sequential("verifyKeyBundleSignatures", () => {
27
+ afterEach(() => {
28
+ setCryptoProfile("tweetnacl");
29
+ });
30
+
31
+ it("accepts a valid tweetnacl key bundle", async () => {
32
+ const { device, keyBundle } = await makeBundle("tweetnacl");
33
+
34
+ await expect(
35
+ verifyKeyBundleSignatures(keyBundle, device, "tweetnacl"),
36
+ ).resolves.toBeUndefined();
37
+ });
38
+
39
+ it("rejects a tampered signed prekey public key", async () => {
40
+ const { device, keyBundle } = await makeBundle("tweetnacl");
41
+ const tampered = cloneBundle(keyBundle);
42
+ tampered.preKey.publicKey = flipFirstByte(tampered.preKey.publicKey);
43
+
44
+ await expect(
45
+ verifyKeyBundleSignatures(tampered, device, "tweetnacl"),
46
+ ).rejects.toThrow("signed prekey signature is invalid");
47
+ });
48
+
49
+ it("rejects a tampered one-time prekey public key", async () => {
50
+ const { device, keyBundle } = await makeBundle("tweetnacl", true);
51
+ const tampered = cloneBundle(keyBundle);
52
+ if (!tampered.otk) {
53
+ throw new Error("Expected OTK fixture.");
54
+ }
55
+ tampered.otk.publicKey = flipFirstByte(tampered.otk.publicKey);
56
+
57
+ await expect(
58
+ verifyKeyBundleSignatures(tampered, device, "tweetnacl"),
59
+ ).rejects.toThrow("one-time prekey signature is invalid");
60
+ });
61
+
62
+ it("rejects a bundle identity key that does not match the device", async () => {
63
+ const { device, keyBundle } = await makeBundle("tweetnacl");
64
+ const other = await xSignKeyPairAsync();
65
+ const tampered = cloneBundle(keyBundle);
66
+ tampered.signKey = other.publicKey;
67
+
68
+ await expect(
69
+ verifyKeyBundleSignatures(tampered, device, "tweetnacl"),
70
+ ).rejects.toThrow("identity key does not match");
71
+ });
72
+
73
+ it("accepts a valid FIPS key bundle", async () => {
74
+ const { device, keyBundle } = await makeBundle("fips", true);
75
+
76
+ await expect(
77
+ verifyKeyBundleSignatures(keyBundle, device, "fips"),
78
+ ).resolves.toBeUndefined();
79
+ });
80
+ });
81
+
82
+ function cloneBundle(bundle: KeyBundle): KeyBundle {
83
+ return {
84
+ ...(bundle.otk ? { otk: cloneEntry(bundle.otk) } : {}),
85
+ preKey: cloneEntry(bundle.preKey),
86
+ signKey: Uint8Array.from(bundle.signKey),
87
+ };
88
+ }
89
+
90
+ function cloneEntry(entry: KeyBundleEntry): KeyBundleEntry {
91
+ return {
92
+ deviceID: entry.deviceID,
93
+ index: entry.index,
94
+ publicKey: Uint8Array.from(entry.publicKey),
95
+ signature: Uint8Array.from(entry.signature),
96
+ };
97
+ }
98
+
99
+ function flipFirstByte(value: Uint8Array): Uint8Array {
100
+ const out = Uint8Array.from(value);
101
+ const first = out[0];
102
+ if (first === undefined) {
103
+ throw new Error("Cannot tamper empty byte array.");
104
+ }
105
+ out[0] = first ^ 0xff;
106
+ return out;
107
+ }
108
+
109
+ async function makeBundle(
110
+ profile: CryptoProfile,
111
+ includeOtk = false,
112
+ ): Promise<{ device: Device; keyBundle: KeyBundle }> {
113
+ setCryptoProfile(profile);
114
+ const signKeys = await xSignKeyPairAsync();
115
+ const identityPublic =
116
+ profile === "fips"
117
+ ? (await xEcdhKeyPairFromEcdsaKeyPairAsync(signKeys)).publicKey
118
+ : signKeys.publicKey;
119
+ const device: Device = {
120
+ deleted: false,
121
+ deviceID: "device-a",
122
+ lastLogin: new Date(0).toISOString(),
123
+ name: "test-device",
124
+ owner: "user-a",
125
+ signKey: XUtils.encodeHex(signKeys.publicKey),
126
+ };
127
+
128
+ const keyBundle: KeyBundle = {
129
+ preKey: await makeBundleEntry(signKeys, profile, device.deviceID, 1),
130
+ signKey: identityPublic,
131
+ };
132
+ if (includeOtk) {
133
+ keyBundle.otk = await makeBundleEntry(
134
+ signKeys,
135
+ profile,
136
+ device.deviceID,
137
+ 2,
138
+ );
139
+ }
140
+
141
+ return { device, keyBundle };
142
+ }
143
+
144
+ async function makeBundleEntry(
145
+ signKeys: KeyPair,
146
+ profile: CryptoProfile,
147
+ deviceID: string,
148
+ index: number,
149
+ ): Promise<KeyBundleEntry> {
150
+ const preKey = await xBoxKeyPairAsync();
151
+ const payload =
152
+ profile === "fips"
153
+ ? fipsP256PreKeySignPayload(preKey.publicKey)
154
+ : xEncode(xConstants.CURVE, preKey.publicKey);
155
+ return {
156
+ deviceID,
157
+ index,
158
+ publicKey: preKey.publicKey,
159
+ signature: await xSignAsync(payload, signKeys.secretKey),
160
+ };
161
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import type { CryptoProfile } from "@vex-chat/crypto";
8
+ import type { Device, KeyBundle, KeyBundleEntry } from "@vex-chat/types";
9
+
10
+ import {
11
+ fipsEcdhRawPublicKeyFromEcdsaSpkiAsync,
12
+ xConstants,
13
+ xEncode,
14
+ xSignOpenAsync,
15
+ XUtils,
16
+ } from "@vex-chat/crypto";
17
+
18
+ import { fipsP256PreKeySignPayload } from "./fipsMailExtra.js";
19
+
20
+ export async function verifyKeyBundleSignatures(
21
+ keyBundle: KeyBundle,
22
+ device: Device,
23
+ cryptoProfile: CryptoProfile,
24
+ ): Promise<void> {
25
+ const deviceSignKey = XUtils.decodeHex(device.signKey);
26
+ const expectedIdentity =
27
+ cryptoProfile === "fips"
28
+ ? await fipsEcdhRawPublicKeyFromEcdsaSpkiAsync(deviceSignKey)
29
+ : deviceSignKey;
30
+
31
+ if (!XUtils.bytesEqual(expectedIdentity, keyBundle.signKey)) {
32
+ throw new Error("Key bundle identity key does not match device.");
33
+ }
34
+
35
+ await verifyKeyBundleEntrySignature(
36
+ keyBundle.preKey,
37
+ device,
38
+ deviceSignKey,
39
+ cryptoProfile,
40
+ "signed prekey",
41
+ );
42
+
43
+ if (keyBundle.otk) {
44
+ await verifyKeyBundleEntrySignature(
45
+ keyBundle.otk,
46
+ device,
47
+ deviceSignKey,
48
+ cryptoProfile,
49
+ "one-time prekey",
50
+ );
51
+ }
52
+ }
53
+
54
+ async function verifyKeyBundleEntrySignature(
55
+ entry: KeyBundleEntry,
56
+ device: Device,
57
+ deviceSignKey: Uint8Array,
58
+ cryptoProfile: CryptoProfile,
59
+ label: string,
60
+ ): Promise<void> {
61
+ if (entry.deviceID !== device.deviceID) {
62
+ throw new Error(`Key bundle ${label} belongs to a different device.`);
63
+ }
64
+
65
+ const payload =
66
+ cryptoProfile === "fips"
67
+ ? fipsP256PreKeySignPayload(entry.publicKey)
68
+ : xEncode(xConstants.CURVE, entry.publicKey);
69
+ const opened = await xSignOpenAsync(entry.signature, deviceSignKey);
70
+
71
+ if (!opened || !XUtils.bytesEqual(opened, payload)) {
72
+ throw new Error(`Key bundle ${label} signature is invalid.`);
73
+ }
74
+ }