@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/dist/Client.d.ts +4 -4
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +15 -16
- package/dist/Client.js.map +1 -1
- package/dist/codecs.d.ts +8 -8
- package/dist/codecs.js +2 -2
- package/dist/codecs.js.map +1 -1
- package/dist/storage/sqlite.d.ts +0 -1
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +2 -19
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/utils/ratchet.d.ts +3 -0
- package/dist/utils/ratchet.d.ts.map +1 -1
- package/dist/utils/ratchet.js +49 -4
- package/dist/utils/ratchet.js.map +1 -1
- package/dist/utils/sqlSessionToCrypto.d.ts.map +1 -1
- package/dist/utils/sqlSessionToCrypto.js +2 -19
- package/dist/utils/sqlSessionToCrypto.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +21 -20
- package/src/__tests__/ratchet.test.ts +44 -0
- package/src/codecs.ts +2 -2
- package/src/storage/sqlite.ts +3 -19
- package/src/utils/ratchet.ts +65 -4
- package/src/utils/sqlSessionToCrypto.ts +3 -19
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
|
|
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 -
|
|
1698
|
-
* @param 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
|
|
1708
|
-
password
|
|
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:
|
|
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
|
);
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -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 =
|
|
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,
|
package/src/utils/ratchet.ts
CHANGED
|
@@ -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
|
|
127
|
-
|
|
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
|
|
240
|
-
|
|
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 =
|
|
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
|
-
}
|