@vex-chat/libvex 6.0.1 → 6.1.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
@@ -498,7 +498,7 @@ export interface PendingDeviceRequest {
498
498
  requestID: string;
499
499
  signKey: string;
500
500
  status: PendingDeviceApprovalStatus;
501
- username: string;
501
+ username?: string | undefined;
502
502
  }
503
503
 
504
504
  /**
@@ -1694,8 +1694,8 @@ export class Client {
1694
1694
  /**
1695
1695
  * Registers a new account on the server.
1696
1696
  *
1697
- * @param username - The username to register. Must be unique.
1698
- * @param password - Account password.
1697
+ * @param username - Optional username to register (must be unique when provided).
1698
+ * @param password - Optional account password (device-key auth works without it).
1699
1699
  * @returns `[user, null]` on success, `[null, error]` on failure.
1700
1700
  *
1701
1701
  * @example
@@ -1704,14 +1704,22 @@ export class Client {
1704
1704
  * ```
1705
1705
  */
1706
1706
  public async register(
1707
- username: string,
1708
- password: string,
1707
+ username?: string,
1708
+ password?: string,
1709
1709
  ): Promise<[null | User, Error | null]> {
1710
1710
  while (!this.xKeyRing) {
1711
1711
  await sleep(100);
1712
1712
  }
1713
1713
  const regKey = await this.getToken("register");
1714
1714
  if (regKey) {
1715
+ const resolvedUsername =
1716
+ username?.trim().length !== 0 && username !== undefined
1717
+ ? username.trim()
1718
+ : Client.randomUsername();
1719
+ const resolvedPassword =
1720
+ password?.trim().length !== 0 && password !== undefined
1721
+ ? password
1722
+ : crypto.randomUUID();
1715
1723
  const signKey = XUtils.encodeHex(this.signKeys.publicKey);
1716
1724
  const signed = XUtils.encodeHex(
1717
1725
  await xSignAsync(
@@ -1722,7 +1730,7 @@ export class Client {
1722
1730
  const preKeyIndex = this.xKeyRing.preKeys.index;
1723
1731
  const regMsg: RegistrationPayload = {
1724
1732
  deviceName: this.options?.deviceName ?? "unknown",
1725
- password,
1733
+ password: resolvedPassword,
1726
1734
  preKey: XUtils.encodeHex(
1727
1735
  this.xKeyRing.preKeys.keyPair.publicKey,
1728
1736
  ),
@@ -1732,7 +1740,7 @@ export class Client {
1732
1740
  ),
1733
1741
  signed,
1734
1742
  signKey,
1735
- username,
1743
+ username: resolvedUsername,
1736
1744
  };
1737
1745
  try {
1738
1746
  const res = await this.http.post(
@@ -3476,18 +3484,7 @@ export class Client {
3476
3484
  }
3477
3485
 
3478
3486
  const token = await this.getToken("device");
3479
-
3480
- const username = this.user?.username;
3481
- if (!username) {
3482
- throw new Error("No user set — log in first.");
3483
- }
3484
- const [userDetails, err] = await this.fetchUser(username);
3485
- if (!userDetails) {
3486
- throw new Error("Username not found " + username);
3487
- }
3488
- if (err) {
3489
- throw err;
3490
- }
3487
+ const userDetails = this.getUser();
3491
3488
  if (!token) {
3492
3489
  throw new Error("Couldn't fetch token.");
3493
3490
  }
@@ -3504,6 +3501,10 @@ export class Client {
3504
3501
  );
3505
3502
 
3506
3503
  const devPreKeyIndex = this.xKeyRing.preKeys.index;
3504
+ const normalizedUsername =
3505
+ userDetails.username.trim().length > 0
3506
+ ? userDetails.username
3507
+ : `key_${userDetails.userID.replaceAll("-", "").slice(0, 12)}`;
3507
3508
  const devMsg: DevicePayload = {
3508
3509
  deviceName: this.options?.deviceName ?? "unknown",
3509
3510
  preKey: XUtils.encodeHex(this.xKeyRing.preKeys.keyPair.publicKey),
@@ -3511,7 +3512,7 @@ export class Client {
3511
3512
  preKeySignature: XUtils.encodeHex(this.xKeyRing.preKeys.signature),
3512
3513
  signed,
3513
3514
  signKey,
3514
- username: userDetails.username,
3515
+ username: normalizedUsername,
3515
3516
  };
3516
3517
 
3517
3518
  const res = await this.http.post(
@@ -14,6 +14,9 @@ import {
14
14
  encodeRatchetHeader,
15
15
  hasRemoteDhChanged,
16
16
  initRatchetSession,
17
+ MAX_SKIP_MESSAGE_GAP,
18
+ MAX_SKIPPED_KEYS,
19
+ parseSkippedKeysStrict,
17
20
  ratchetStepReceive,
18
21
  ratchetStepSend,
19
22
  sessionToSqlPatch,
@@ -417,6 +420,47 @@ describe("double ratchet helpers", () => {
417
420
 
418
421
  expect(alice.Nr + alice.Ns + bob.Nr + bob.Ns).toBeGreaterThan(500);
419
422
  });
423
+
424
+ it("rejects excessive receive message gap", () => {
425
+ const state = {
426
+ CKr: XUtils.decodeHex(
427
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
428
+ ),
429
+ DHr: XUtils.decodeHex(
430
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
431
+ ),
432
+ Nr: 0,
433
+ skippedKeys: {} as Record<string, string>,
434
+ };
435
+
436
+ expect(() =>
437
+ takeReceiveMessageKey(state, state.DHr, MAX_SKIP_MESSAGE_GAP + 1),
438
+ ).toThrow("Ratchet skip window exceeded");
439
+ });
440
+
441
+ it("sanitizes skipped-keys payload bounds and format", () => {
442
+ const validDh = "aa".repeat(32);
443
+ const validValue = "bb".repeat(32);
444
+ const oversized = Object.fromEntries(
445
+ Array.from({ length: MAX_SKIPPED_KEYS + 50 }, (_v, i) => [
446
+ `${validDh}:${String(i)}`,
447
+ validValue,
448
+ ]),
449
+ ) as Record<string, string>;
450
+ const bounded = parseSkippedKeysStrict(JSON.stringify(oversized));
451
+ expect(Object.keys(bounded).length).toBe(MAX_SKIPPED_KEYS);
452
+
453
+ const filtered = parseSkippedKeysStrict(
454
+ JSON.stringify({
455
+ [`${validDh}:0`]: "not-hex",
456
+ [`${validDh}:1`]: validValue,
457
+ "bad-key-format": validValue,
458
+ }),
459
+ );
460
+ expect(filtered["bad-key-format"]).toBeUndefined();
461
+ expect(filtered[`${validDh}:0`]).toBeUndefined();
462
+ expect(filtered[`${validDh}:1`]).toBe(validValue);
463
+ });
420
464
  });
421
465
 
422
466
  function hydrateState(
package/src/codecs.ts CHANGED
@@ -100,7 +100,7 @@ export const PendingDeviceRequestCodec = createCodec(
100
100
  z.literal("pending"),
101
101
  z.literal("rejected"),
102
102
  ]),
103
- username: z.string(),
103
+ username: z.string().optional(),
104
104
  }),
105
105
  );
106
106
 
@@ -120,7 +120,7 @@ export const PendingDeviceRequestArrayCodec = createCodec(
120
120
  z.literal("pending"),
121
121
  z.literal("rejected"),
122
122
  ]),
123
- username: z.string(),
123
+ username: z.string().optional(),
124
124
  }),
125
125
  ),
126
126
  );
@@ -44,6 +44,8 @@ import {
44
44
  import { EventEmitter } from "eventemitter3";
45
45
  import { type Kysely, sql } from "kysely";
46
46
 
47
+ import { parseSkippedKeysStrict } from "../utils/ratchet.js";
48
+
47
49
  export class SqliteStorage extends EventEmitter implements Storage {
48
50
  public ready = false;
49
51
  /** 32-byte AES-256 (or nacl) key for local at-rest `secretbox` (see `XUtils.deriveLocalAtRestAesKey`). */
@@ -730,24 +732,6 @@ export class SqliteStorage extends EventEmitter implements Storage {
730
732
  return false;
731
733
  }
732
734
 
733
- private parseSkippedKeys(raw: string): Record<string, string> {
734
- try {
735
- const parsed: unknown = JSON.parse(raw);
736
- if (typeof parsed !== "object" || parsed === null) {
737
- return {};
738
- }
739
- const out: Record<string, string> = {};
740
- for (const [k, v] of Object.entries(parsed)) {
741
- if (typeof v === "string") {
742
- out[k] = v;
743
- }
744
- }
745
- return out;
746
- } catch {
747
- return {};
748
- }
749
- }
750
-
751
735
  /**
752
736
  * Encrypt a hex-encoded secret for at-rest storage.
753
737
  * Returns hex(nonce || ciphertext) where nonce is 24 random bytes.
@@ -797,7 +781,7 @@ export class SqliteStorage extends EventEmitter implements Storage {
797
781
  }
798
782
 
799
783
  private sqlToCrypto(session: SessionSQL): SessionCrypto {
800
- const skippedKeys = this.parseSkippedKeys(session.skippedKeys);
784
+ const skippedKeys = parseSkippedKeysStrict(session.skippedKeys);
801
785
  return {
802
786
  CKr: session.CKr ? XUtils.decodeHex(session.CKr) : null,
803
787
  CKs: session.CKs ? XUtils.decodeHex(session.CKs) : null,
@@ -16,6 +16,8 @@ import {
16
16
  } from "@vex-chat/crypto";
17
17
 
18
18
  const VERSION = 1;
19
+ export const MAX_SKIP_MESSAGE_GAP = 1024;
20
+ export const MAX_SKIPPED_KEYS = 4096;
19
21
 
20
22
  const encoder = new TextEncoder();
21
23
 
@@ -103,6 +105,25 @@ export async function initRatchetSession(
103
105
  };
104
106
  }
105
107
 
108
+ export function parseSkippedKeysStrict(raw: string): Record<string, string> {
109
+ try {
110
+ const parsed: unknown = JSON.parse(raw);
111
+ if (typeof parsed !== "object" || parsed === null) {
112
+ return {};
113
+ }
114
+ const entries = Object.entries(parsed).slice(0, MAX_SKIPPED_KEYS);
115
+ const out: Record<string, string> = {};
116
+ for (const [k, v] of entries) {
117
+ if (typeof v === "string" && isHex(v) && isSkippedKeyIdFormat(k)) {
118
+ out[k] = v;
119
+ }
120
+ }
121
+ return out;
122
+ } catch {
123
+ return {};
124
+ }
125
+ }
126
+
106
127
  export async function ratchetStepReceive(
107
128
  state: {
108
129
  CKr: null | Uint8Array;
@@ -120,11 +141,17 @@ export async function ratchetStepReceive(
120
141
  pn: number,
121
142
  ): Promise<void> {
122
143
  if (state.CKr && state.DHr) {
144
+ if (pn - state.Nr > MAX_SKIP_MESSAGE_GAP) {
145
+ throw new Error("Ratchet skip window exceeded for PN.");
146
+ }
123
147
  while (state.Nr < pn) {
124
148
  const { chainKey, messageKey } = kdfChain(state.CKr);
125
149
  state.CKr = chainKey;
126
- state.skippedKeys[skippedKeyId(state.DHr, state.Nr)] =
127
- XUtils.encodeHex(messageKey);
150
+ state.skippedKeys = putSkippedMessageKey(
151
+ state.skippedKeys,
152
+ skippedKeyId(state.DHr, state.Nr),
153
+ XUtils.encodeHex(messageKey),
154
+ );
128
155
  state.Nr += 1;
129
156
  }
130
157
  }
@@ -230,14 +257,21 @@ export function takeReceiveMessageKey(
230
257
  throw new Error("Missing receiving chain key.");
231
258
  }
232
259
 
260
+ if (n - state.Nr > MAX_SKIP_MESSAGE_GAP) {
261
+ throw new Error("Ratchet skip window exceeded for message index.");
262
+ }
263
+
233
264
  while (state.Nr < n) {
234
265
  const { chainKey, messageKey } = kdfChain(state.CKr);
235
266
  state.CKr = chainKey;
236
267
  if (!state.DHr) {
237
268
  throw new Error("Missing DHr when storing skipped key.");
238
269
  }
239
- state.skippedKeys[skippedKeyId(state.DHr, state.Nr)] =
240
- XUtils.encodeHex(messageKey);
270
+ state.skippedKeys = putSkippedMessageKey(
271
+ state.skippedKeys,
272
+ skippedKeyId(state.DHr, state.Nr),
273
+ XUtils.encodeHex(messageKey),
274
+ );
241
275
  state.Nr += 1;
242
276
  }
243
277
 
@@ -261,6 +295,20 @@ export function takeSendMessageKey(state: {
261
295
  return { messageKey, n };
262
296
  }
263
297
 
298
+ function isHex(value: string): boolean {
299
+ return value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value);
300
+ }
301
+
302
+ function isSkippedKeyIdFormat(value: string): boolean {
303
+ const idx = value.lastIndexOf(":");
304
+ if (idx <= 0 || idx === value.length - 1) {
305
+ return false;
306
+ }
307
+ const dhHex = value.slice(0, idx);
308
+ const nPart = value.slice(idx + 1);
309
+ return isHex(dhHex) && /^[0-9]+$/.test(nPart);
310
+ }
311
+
264
312
  function kdfChain(ck: Uint8Array): {
265
313
  chainKey: Uint8Array;
266
314
  messageKey: Uint8Array;
@@ -282,6 +330,19 @@ function kdfRoot(
282
330
  };
283
331
  }
284
332
 
333
+ function putSkippedMessageKey(
334
+ skippedKeys: Record<string, string>,
335
+ id: string,
336
+ keyHex: string,
337
+ ): Record<string, string> {
338
+ let entries = Object.entries(skippedKeys).filter(([key]) => key !== id);
339
+ if (entries.length >= MAX_SKIPPED_KEYS) {
340
+ entries = entries.slice(entries.length - MAX_SKIPPED_KEYS + 1);
341
+ }
342
+ entries.push([id, keyHex]);
343
+ return Object.fromEntries(entries);
344
+ }
345
+
285
346
  function skippedKeyId(dhPub: Uint8Array, n: number): string {
286
347
  return `${XUtils.encodeHex(dhPub)}:${String(n)}`;
287
348
  }
@@ -9,8 +9,10 @@ import type { SessionSQL } from "@vex-chat/types";
9
9
 
10
10
  import { XUtils } from "@vex-chat/crypto";
11
11
 
12
+ import { parseSkippedKeysStrict } from "./ratchet.js";
13
+
12
14
  export function sqlSessionToCrypto(session: SessionSQL): SessionCrypto {
13
- const skippedKeys = parseSkippedKeys(session.skippedKeys);
15
+ const skippedKeys = parseSkippedKeysStrict(session.skippedKeys);
14
16
  return {
15
17
  CKr: session.CKr ? XUtils.decodeHex(session.CKr) : null,
16
18
  CKs: session.CKs ? XUtils.decodeHex(session.CKs) : null,
@@ -32,21 +34,3 @@ export function sqlSessionToCrypto(session: SessionSQL): SessionCrypto {
32
34
  verified: session.verified,
33
35
  };
34
36
  }
35
-
36
- function parseSkippedKeys(raw: string): Record<string, string> {
37
- try {
38
- const parsed: unknown = JSON.parse(raw);
39
- if (typeof parsed !== "object" || parsed === null) {
40
- return {};
41
- }
42
- const out: Record<string, string> = {};
43
- for (const [k, v] of Object.entries(parsed)) {
44
- if (typeof v === "string") {
45
- out[k] = v;
46
- }
47
- }
48
- return out;
49
- } catch {
50
- return {};
51
- }
52
- }