@vex-chat/libvex 8.0.0 → 9.0.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 +34 -21
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +193 -131
- package/dist/Client.js.map +1 -1
- package/dist/codecs.d.ts +8 -4
- package/dist/codecs.d.ts.map +1 -1
- package/dist/codecs.js +2 -5
- package/dist/codecs.js.map +1 -1
- package/dist/http.js +15 -1
- package/dist/http.js.map +1 -1
- package/dist/keystore/node.d.ts.map +1 -1
- package/dist/keystore/node.js +9 -11
- package/dist/keystore/node.js.map +1 -1
- package/dist/storage/sqlite.d.ts +6 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +89 -56
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/utils/verifyKeyBundle.d.ts.map +1 -1
- package/dist/utils/verifyKeyBundle.js +7 -10
- package/dist/utils/verifyKeyBundle.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +305 -180
- package/src/__tests__/harness/shared-suite.ts +82 -11
- package/src/__tests__/http.test.ts +22 -0
- package/src/__tests__/node-keystore.test.ts +69 -0
- package/src/__tests__/prekey-reuse.test.ts +13 -13
- package/src/__tests__/storage-sqlite.test.ts +90 -1
- package/src/__tests__/verifyKeyBundle.test.ts +11 -8
- package/src/codecs.ts +2 -5
- package/src/http.ts +16 -1
- package/src/keystore/node.ts +17 -19
- package/src/storage/sqlite.ts +109 -84
- package/src/utils/verifyKeyBundle.ts +13 -13
|
@@ -79,11 +79,46 @@ export function platformSuite(
|
|
|
79
79
|
expect(true).toBe(true);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
test("
|
|
82
|
+
test("existing-account device request requires password", async () => {
|
|
83
|
+
const SK2 = await e2eGenerateSecretKey();
|
|
84
|
+
const opts2: ClientOptions = e2eClientOptionsBase();
|
|
85
|
+
const storage2 = await makeStorage(SK2, opts2);
|
|
86
|
+
const client2 = await Client.create(SK2, opts2, storage2);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const [duplicateUser, duplicateErr] = await client2.register(
|
|
90
|
+
username,
|
|
91
|
+
password,
|
|
92
|
+
);
|
|
93
|
+
expect(duplicateUser).toBeNull();
|
|
94
|
+
expect(duplicateErr?.message).toContain(
|
|
95
|
+
"Username is already registered. Sign in instead.",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const [missingPasswordUser, missingPasswordErr] =
|
|
99
|
+
await client2.requestDeviceEnrollmentWithPasskey(username);
|
|
100
|
+
expect(missingPasswordUser).toBeNull();
|
|
101
|
+
expect(missingPasswordErr?.message).toContain(
|
|
102
|
+
"Password is required to add this device.",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const [pendingUser, pendingErr] =
|
|
106
|
+
await client2.requestDeviceEnrollment(username, password);
|
|
107
|
+
expect(pendingUser).toBeNull();
|
|
108
|
+
expect(pendingErr?.name).toBe("DeviceApprovalRequiredError");
|
|
109
|
+
expect(
|
|
110
|
+
(pendingErr as null | { requestID?: unknown })?.requestID,
|
|
111
|
+
).toEqual(expect.any(String));
|
|
112
|
+
} finally {
|
|
113
|
+
await client2.close().catch(() => {});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("send self-DM without duplicating it as incoming", async () => {
|
|
83
118
|
const me = client.me.user();
|
|
84
119
|
const msgPromise = waitForMessage(
|
|
85
120
|
client,
|
|
86
|
-
(m) => m.direction === "
|
|
121
|
+
(m) => m.direction === "outgoing" && m.decrypted,
|
|
87
122
|
`[${platformName}] self-DM`,
|
|
88
123
|
);
|
|
89
124
|
await client.messages.send(me.userID, "platform-test");
|
|
@@ -91,7 +126,7 @@ export function platformSuite(
|
|
|
91
126
|
expect(msg.message).toBe("platform-test");
|
|
92
127
|
});
|
|
93
128
|
|
|
94
|
-
test("send
|
|
129
|
+
test("send self-DM with encrypted extra", async () => {
|
|
95
130
|
const me = client.me.user();
|
|
96
131
|
const extra = JSON.stringify({
|
|
97
132
|
reactionEvent: {
|
|
@@ -104,7 +139,7 @@ export function platformSuite(
|
|
|
104
139
|
const msgPromise = waitForMessage(
|
|
105
140
|
client,
|
|
106
141
|
(m) =>
|
|
107
|
-
m.direction === "
|
|
142
|
+
m.direction === "outgoing" &&
|
|
108
143
|
m.decrypted &&
|
|
109
144
|
m.extra === extra,
|
|
110
145
|
`[${platformName}] self-DM extra`,
|
|
@@ -127,7 +162,7 @@ export function platformSuite(
|
|
|
127
162
|
const msgPromise = waitForMessage(
|
|
128
163
|
client,
|
|
129
164
|
(m) =>
|
|
130
|
-
m.direction === "
|
|
165
|
+
m.direction === "outgoing" &&
|
|
131
166
|
m.decrypted &&
|
|
132
167
|
m.message === body,
|
|
133
168
|
"history DM",
|
|
@@ -156,11 +191,14 @@ export function platformSuite(
|
|
|
156
191
|
try {
|
|
157
192
|
const [user2, regErr] = await client2.register(
|
|
158
193
|
username2,
|
|
159
|
-
"test
|
|
194
|
+
"test password two",
|
|
160
195
|
);
|
|
161
196
|
expect(regErr).toBeNull();
|
|
162
197
|
|
|
163
|
-
const loginErr = await client2.login(
|
|
198
|
+
const loginErr = await client2.login(
|
|
199
|
+
username2,
|
|
200
|
+
"test password two",
|
|
201
|
+
);
|
|
164
202
|
expect(loginErr.ok).toBe(true);
|
|
165
203
|
|
|
166
204
|
await connectAndWait(client2, "client2");
|
|
@@ -186,14 +224,14 @@ export function platformSuite(
|
|
|
186
224
|
const storage1 = await makeStorage(SK1, opts1);
|
|
187
225
|
const client1 = await Client.create(SK1, opts1, storage1);
|
|
188
226
|
const username1 = Client.randomUsername();
|
|
189
|
-
const password1 = "test
|
|
227
|
+
const password1 = "test password one";
|
|
190
228
|
|
|
191
229
|
const SK2 = await e2eGenerateSecretKey();
|
|
192
230
|
const opts2: ClientOptions = e2eClientOptionsBase();
|
|
193
231
|
const storage2 = await makeStorage(SK2, opts2);
|
|
194
232
|
const client2 = await Client.create(SK2, opts2, storage2);
|
|
195
233
|
const username2 = Client.randomUsername();
|
|
196
|
-
const password2 = "test
|
|
234
|
+
const password2 = "test password two";
|
|
197
235
|
|
|
198
236
|
try {
|
|
199
237
|
const [_user1, regErr1] = await client1.register(
|
|
@@ -264,8 +302,16 @@ export function platformSuite(
|
|
|
264
302
|
|
|
265
303
|
try {
|
|
266
304
|
// Register + login + connect user2
|
|
267
|
-
await client2.register(
|
|
268
|
-
|
|
305
|
+
const [_user2, registrationError] = await client2.register(
|
|
306
|
+
username2,
|
|
307
|
+
"test password two",
|
|
308
|
+
);
|
|
309
|
+
expect(registrationError).toBeNull();
|
|
310
|
+
const loginResult = await client2.login(
|
|
311
|
+
username2,
|
|
312
|
+
"test password two",
|
|
313
|
+
);
|
|
314
|
+
expect(loginResult.ok).toBe(true);
|
|
269
315
|
await connectAndWait(client2, "client2");
|
|
270
316
|
|
|
271
317
|
// user1 creates server + channel
|
|
@@ -504,6 +550,31 @@ export function platformSuite(
|
|
|
504
550
|
});
|
|
505
551
|
}
|
|
506
552
|
});
|
|
553
|
+
|
|
554
|
+
test("changes the account password with current-password proof", async () => {
|
|
555
|
+
const replacementPassword = "updated platform test password";
|
|
556
|
+
|
|
557
|
+
await expect(
|
|
558
|
+
client.me.changePassword(
|
|
559
|
+
"incorrect platform test password",
|
|
560
|
+
replacementPassword,
|
|
561
|
+
),
|
|
562
|
+
).rejects.toThrow();
|
|
563
|
+
await client.me.changePassword(password, replacementPassword);
|
|
564
|
+
await client.logout();
|
|
565
|
+
|
|
566
|
+
const oldLogin = await client.login(username, password);
|
|
567
|
+
expect(oldLogin.ok).toBe(false);
|
|
568
|
+
const newLogin = await client.login(username, replacementPassword);
|
|
569
|
+
expect(newLogin.ok).toBe(true);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("logout clears local authentication", async () => {
|
|
573
|
+
await client.logout();
|
|
574
|
+
await expect(client.connect()).rejects.toThrow(
|
|
575
|
+
"No token — call login() or loginWithDeviceKey() first.",
|
|
576
|
+
);
|
|
577
|
+
});
|
|
507
578
|
});
|
|
508
579
|
}
|
|
509
580
|
|
|
@@ -68,6 +68,28 @@ describe("FetchHttpClient", () => {
|
|
|
68
68
|
expect(err.response?.data).toBe("plain failure");
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
it("redacts credentials from request metadata on errors", async () => {
|
|
72
|
+
globalThis.fetch = () =>
|
|
73
|
+
Promise.resolve(new Response(null, { status: 401 }));
|
|
74
|
+
const client = createFetchHttpClient();
|
|
75
|
+
client.defaults.headers.common.Authorization =
|
|
76
|
+
"Bearer secret-user-token";
|
|
77
|
+
client.defaults.headers.common["X-Device-Token"] =
|
|
78
|
+
"secret-device-token";
|
|
79
|
+
|
|
80
|
+
const err = await captureError(() =>
|
|
81
|
+
client.get("https://example.test/private"),
|
|
82
|
+
);
|
|
83
|
+
if (!isHttpError(err)) {
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
expect(err.config.headers["authorization"]).toBe("[REDACTED]");
|
|
88
|
+
expect(err.config.headers["x-device-token"]).toBe("[REDACTED]");
|
|
89
|
+
expect(JSON.stringify(err)).not.toContain("secret-user-token");
|
|
90
|
+
expect(JSON.stringify(err)).not.toContain("secret-device-token");
|
|
91
|
+
});
|
|
92
|
+
|
|
71
93
|
it("emits a final upload progress event for FormData payloads", async () => {
|
|
72
94
|
globalThis.fetch = () =>
|
|
73
95
|
Promise.resolve(new Response(new ArrayBuffer(0), { status: 200 }));
|
|
@@ -0,0 +1,69 @@
|
|
|
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 { StoredCredentials } from "../types/index.js";
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { NodeKeyStore } from "../keystore/node.js";
|
|
16
|
+
|
|
17
|
+
const dirs: string[] = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const dir of dirs.splice(0)) {
|
|
21
|
+
fs.rmSync(dir, { force: true, recursive: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("NodeKeyStore", () => {
|
|
26
|
+
it("round-trips credentials through the encrypted file", async () => {
|
|
27
|
+
const dir = makeTempDir();
|
|
28
|
+
const store = new NodeKeyStore("correct horse battery staple", dir);
|
|
29
|
+
const credentials: StoredCredentials = {
|
|
30
|
+
deviceID: "device-id",
|
|
31
|
+
deviceKey: "ab".repeat(64),
|
|
32
|
+
preKey: "cd".repeat(32),
|
|
33
|
+
token: "saved-token",
|
|
34
|
+
username: "alice",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
await store.save(credentials);
|
|
38
|
+
|
|
39
|
+
await expect(store.load("alice")).resolves.toEqual(credentials);
|
|
40
|
+
await expect(store.load()).resolves.toEqual(credentials);
|
|
41
|
+
const raw = fs.readFileSync(path.join(dir, "alice.vex"));
|
|
42
|
+
expect(raw.toString("utf8")).not.toContain("saved-token");
|
|
43
|
+
expect(fs.statSync(path.join(dir, "alice.vex")).mode & 0o777).toBe(
|
|
44
|
+
0o600,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps usernames inside the configured directory", async () => {
|
|
49
|
+
const dir = makeTempDir();
|
|
50
|
+
const store = new NodeKeyStore("password", dir);
|
|
51
|
+
|
|
52
|
+
await store.save({
|
|
53
|
+
deviceID: "device-id",
|
|
54
|
+
deviceKey: "ab".repeat(64),
|
|
55
|
+
username: "../outside",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(fs.readdirSync(dir)).toEqual(["..%2Foutside.vex"]);
|
|
59
|
+
await expect(store.load("../outside")).resolves.toMatchObject({
|
|
60
|
+
username: "../outside",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function makeTempDir(): string {
|
|
66
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vex-keystore-"));
|
|
67
|
+
dirs.push(dir);
|
|
68
|
+
return dir;
|
|
69
|
+
}
|
|
@@ -10,10 +10,9 @@ import type { CryptoProfile, KeyPair } from "@vex-chat/crypto";
|
|
|
10
10
|
import {
|
|
11
11
|
setCryptoProfile,
|
|
12
12
|
xBoxKeyPairAsync,
|
|
13
|
-
xConstants,
|
|
14
13
|
xEcdhKeyPairFromEcdsaKeyPairAsync,
|
|
15
|
-
xEncode,
|
|
16
14
|
XKeyConvert,
|
|
15
|
+
xPreKeySignaturePayload,
|
|
17
16
|
xSignAsync,
|
|
18
17
|
xSignKeyPair,
|
|
19
18
|
xSignKeyPairAsync,
|
|
@@ -24,12 +23,11 @@ import {
|
|
|
24
23
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
25
24
|
|
|
26
25
|
import { Client } from "../Client.js";
|
|
27
|
-
import { fipsP256PreKeySignPayload } from "../utils/fipsMailExtra.js";
|
|
28
26
|
|
|
29
27
|
import { MemoryStorage } from "./harness/memory-storage.js";
|
|
30
28
|
|
|
31
29
|
interface KeyRingHarness {
|
|
32
|
-
createPreKey: () => Promise<UnsavedPreKey>;
|
|
30
|
+
createPreKey: (kind: "one-time" | "signed") => Promise<UnsavedPreKey>;
|
|
33
31
|
cryptoProfile: CryptoProfile;
|
|
34
32
|
database: MemoryStorage;
|
|
35
33
|
idKeys: KeyPair;
|
|
@@ -41,7 +39,7 @@ interface KeyRingHarness {
|
|
|
41
39
|
}
|
|
42
40
|
|
|
43
41
|
const clientMethods = Client.prototype as unknown as {
|
|
44
|
-
createPreKey: () => Promise<UnsavedPreKey>;
|
|
42
|
+
createPreKey: (kind: "one-time" | "signed") => Promise<UnsavedPreKey>;
|
|
45
43
|
isPreKeySignedByCurrentDevice: (preKey: PreKeysCrypto) => Promise<boolean>;
|
|
46
44
|
populateKeyRing: () => Promise<void>;
|
|
47
45
|
runWithThisCryptoProfile: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
@@ -54,10 +52,11 @@ async function isValidFor(
|
|
|
54
52
|
): Promise<boolean> {
|
|
55
53
|
setCryptoProfile(profile);
|
|
56
54
|
const opened = await xSignOpenAsync(preKey.signature, signKeys.publicKey);
|
|
57
|
-
const payload =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
const payload = xPreKeySignaturePayload(
|
|
56
|
+
preKey.keyPair.publicKey,
|
|
57
|
+
"signed",
|
|
58
|
+
profile,
|
|
59
|
+
);
|
|
61
60
|
return Boolean(opened && XUtils.bytesEqual(opened, payload));
|
|
62
61
|
}
|
|
63
62
|
|
|
@@ -67,10 +66,11 @@ async function makeSignedPreKey(
|
|
|
67
66
|
): Promise<UnsavedPreKey> {
|
|
68
67
|
setCryptoProfile(profile);
|
|
69
68
|
const keyPair = await xBoxKeyPairAsync();
|
|
70
|
-
const payload =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
const payload = xPreKeySignaturePayload(
|
|
70
|
+
keyPair.publicKey,
|
|
71
|
+
"signed",
|
|
72
|
+
profile,
|
|
73
|
+
);
|
|
74
74
|
return {
|
|
75
75
|
keyPair,
|
|
76
76
|
signature: await xSignAsync(payload, signKeys.secretKey),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Message } from "../index.js";
|
|
2
2
|
import type { ClientDatabase } from "../storage/schema.js";
|
|
3
|
+
import type { SessionSQL } from "@vex-chat/types";
|
|
3
4
|
|
|
4
5
|
import BetterSqlite3 from "better-sqlite3";
|
|
5
6
|
import { Kysely, SqliteDialect } from "kysely";
|
|
@@ -74,6 +75,69 @@ describe("SqliteStorage message at-rest encryption", () => {
|
|
|
74
75
|
await storage.close();
|
|
75
76
|
}
|
|
76
77
|
});
|
|
78
|
+
|
|
79
|
+
it("uses a fresh local nonce when a stored message is edited", async () => {
|
|
80
|
+
const { db, storage } = makeStorage();
|
|
81
|
+
try {
|
|
82
|
+
await storage.init();
|
|
83
|
+
const message = makeMessage({
|
|
84
|
+
mailID: "editable-mail",
|
|
85
|
+
message: "before",
|
|
86
|
+
nonce: nonceHex(3),
|
|
87
|
+
});
|
|
88
|
+
await storage.saveMessage(message);
|
|
89
|
+
|
|
90
|
+
const before = await messageRow(db, message.mailID);
|
|
91
|
+
expect(before?.message).toMatch(/^vex-storage-cipher:2:/);
|
|
92
|
+
const localNonce = before?.message
|
|
93
|
+
.slice("vex-storage-cipher:2:".length)
|
|
94
|
+
.slice(0, 48);
|
|
95
|
+
expect(localNonce).not.toBe(message.nonce);
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
storage.updateMessage(message.mailID, { message: "after" }),
|
|
99
|
+
).resolves.toBe(true);
|
|
100
|
+
const after = await messageRow(db, message.mailID);
|
|
101
|
+
expect(after?.message).toMatch(/^vex-storage-cipher:2:/);
|
|
102
|
+
expect(after?.message).not.toBe(before?.message);
|
|
103
|
+
expect(after?.nonce).toBe(message.nonce);
|
|
104
|
+
|
|
105
|
+
const history = await storage.getMessageHistory(message.authorID);
|
|
106
|
+
expect(history).toMatchObject([
|
|
107
|
+
{ mailID: message.mailID, message: "after" },
|
|
108
|
+
]);
|
|
109
|
+
} finally {
|
|
110
|
+
await storage.close();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("encrypts skipped Double Ratchet keys at rest", async () => {
|
|
115
|
+
const { db, storage } = makeStorage();
|
|
116
|
+
try {
|
|
117
|
+
await storage.init();
|
|
118
|
+
const skippedKeys = JSON.stringify({
|
|
119
|
+
"ratchet-key:7": "ab".repeat(32),
|
|
120
|
+
});
|
|
121
|
+
const session = makeSession({ skippedKeys });
|
|
122
|
+
await storage.saveSession(session);
|
|
123
|
+
|
|
124
|
+
const row = await db
|
|
125
|
+
.selectFrom("sessions")
|
|
126
|
+
.select("skippedKeys")
|
|
127
|
+
.where("sessionID", "=", session.sessionID)
|
|
128
|
+
.executeTakeFirstOrThrow();
|
|
129
|
+
expect(row.skippedKeys).toMatch(/^vex-storage-secret:1:/);
|
|
130
|
+
expect(row.skippedKeys).not.toContain("ratchet-key");
|
|
131
|
+
expect(row.skippedKeys).not.toContain("ab".repeat(32));
|
|
132
|
+
|
|
133
|
+
const roundTripped = await storage.getAllSessions();
|
|
134
|
+
expect(roundTripped).toMatchObject([
|
|
135
|
+
{ sessionID: session.sessionID, skippedKeys },
|
|
136
|
+
]);
|
|
137
|
+
} finally {
|
|
138
|
+
await storage.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
77
141
|
});
|
|
78
142
|
|
|
79
143
|
function makeMessage(overrides: Partial<Message>): Message {
|
|
@@ -94,6 +158,31 @@ function makeMessage(overrides: Partial<Message>): Message {
|
|
|
94
158
|
};
|
|
95
159
|
}
|
|
96
160
|
|
|
161
|
+
function makeSession(overrides: Partial<SessionSQL>): SessionSQL {
|
|
162
|
+
return {
|
|
163
|
+
CKr: null,
|
|
164
|
+
CKs: null,
|
|
165
|
+
deviceID: "peer-device",
|
|
166
|
+
DHr: null,
|
|
167
|
+
DHsPrivate: "11".repeat(32),
|
|
168
|
+
DHsPublic: "22".repeat(32),
|
|
169
|
+
fingerprint: "33".repeat(32),
|
|
170
|
+
lastUsed: "2026-06-01T00:00:00.000Z",
|
|
171
|
+
mode: "initiator",
|
|
172
|
+
Nr: 0,
|
|
173
|
+
Ns: 0,
|
|
174
|
+
PN: 0,
|
|
175
|
+
publicKey: "44".repeat(32),
|
|
176
|
+
RK: "55".repeat(32),
|
|
177
|
+
sessionID: "session-id",
|
|
178
|
+
SK: "66".repeat(32),
|
|
179
|
+
skippedKeys: "{}",
|
|
180
|
+
userID: "peer-user",
|
|
181
|
+
verified: false,
|
|
182
|
+
...overrides,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
97
186
|
function makeStorage(): {
|
|
98
187
|
db: Kysely<ClientDatabase>;
|
|
99
188
|
storage: SqliteStorage;
|
|
@@ -112,7 +201,7 @@ function makeStorage(): {
|
|
|
112
201
|
async function messageRow(db: Kysely<ClientDatabase>, mailID: string) {
|
|
113
202
|
return await db
|
|
114
203
|
.selectFrom("messages")
|
|
115
|
-
.select(["decrypted", "extra", "message"])
|
|
204
|
+
.select(["decrypted", "extra", "message", "nonce"])
|
|
116
205
|
.where("mailID", "=", mailID)
|
|
117
206
|
.executeTakeFirst();
|
|
118
207
|
}
|
|
@@ -10,9 +10,8 @@ import type { Device, KeyBundle, KeyBundleEntry } from "@vex-chat/types";
|
|
|
10
10
|
import {
|
|
11
11
|
setCryptoProfile,
|
|
12
12
|
xBoxKeyPairAsync,
|
|
13
|
-
xConstants,
|
|
14
13
|
xEcdhKeyPairFromEcdsaKeyPairAsync,
|
|
15
|
-
|
|
14
|
+
xPreKeySignaturePayload,
|
|
16
15
|
xSignAsync,
|
|
17
16
|
xSignKeyPairAsync,
|
|
18
17
|
XUtils,
|
|
@@ -20,7 +19,6 @@ import {
|
|
|
20
19
|
|
|
21
20
|
import { afterEach, describe, expect, it } from "vitest";
|
|
22
21
|
|
|
23
|
-
import { fipsP256PreKeySignPayload } from "../utils/fipsMailExtra.js";
|
|
24
22
|
import { verifyKeyBundleSignatures } from "../utils/verifyKeyBundle.js";
|
|
25
23
|
|
|
26
24
|
describe.sequential("verifyKeyBundleSignatures", () => {
|
|
@@ -126,7 +124,13 @@ async function makeBundle(
|
|
|
126
124
|
};
|
|
127
125
|
|
|
128
126
|
const keyBundle: KeyBundle = {
|
|
129
|
-
preKey: await makeBundleEntry(
|
|
127
|
+
preKey: await makeBundleEntry(
|
|
128
|
+
signKeys,
|
|
129
|
+
profile,
|
|
130
|
+
device.deviceID,
|
|
131
|
+
1,
|
|
132
|
+
"signed",
|
|
133
|
+
),
|
|
130
134
|
signKey: identityPublic,
|
|
131
135
|
};
|
|
132
136
|
if (includeOtk) {
|
|
@@ -135,6 +139,7 @@ async function makeBundle(
|
|
|
135
139
|
profile,
|
|
136
140
|
device.deviceID,
|
|
137
141
|
2,
|
|
142
|
+
"one-time",
|
|
138
143
|
);
|
|
139
144
|
}
|
|
140
145
|
|
|
@@ -146,12 +151,10 @@ async function makeBundleEntry(
|
|
|
146
151
|
profile: CryptoProfile,
|
|
147
152
|
deviceID: string,
|
|
148
153
|
index: number,
|
|
154
|
+
kind: "one-time" | "signed",
|
|
149
155
|
): Promise<KeyBundleEntry> {
|
|
150
156
|
const preKey = await xBoxKeyPairAsync();
|
|
151
|
-
const payload =
|
|
152
|
-
profile === "fips"
|
|
153
|
-
? fipsP256PreKeySignPayload(preKey.publicKey)
|
|
154
|
-
: xEncode(xConstants.CURVE, preKey.publicKey);
|
|
157
|
+
const payload = xPreKeySignaturePayload(preKey.publicKey, kind, profile);
|
|
155
158
|
return {
|
|
156
159
|
deviceID,
|
|
157
160
|
index,
|
package/src/codecs.ts
CHANGED
|
@@ -93,11 +93,7 @@ export const RegisterPendingApprovalCodec = createCodec(
|
|
|
93
93
|
expiresAt: z.string(),
|
|
94
94
|
requestID: z.string(),
|
|
95
95
|
status: z.literal("pending_approval"),
|
|
96
|
-
|
|
97
|
-
// surface the existing user's ID in this response. When present,
|
|
98
|
-
// clients can use it to fetch the unauthenticated avatar and
|
|
99
|
-
// show an "is this you?" confirmation before proceeding.
|
|
100
|
-
userID: z.string().optional(),
|
|
96
|
+
userID: z.string(),
|
|
101
97
|
}),
|
|
102
98
|
);
|
|
103
99
|
|
|
@@ -116,6 +112,7 @@ export const DeviceRegistrationResultCodec = createCodec(
|
|
|
116
112
|
expiresAt: z.string(),
|
|
117
113
|
requestID: z.string(),
|
|
118
114
|
status: z.literal("pending_approval"),
|
|
115
|
+
userID: z.string(),
|
|
119
116
|
}),
|
|
120
117
|
]),
|
|
121
118
|
);
|
package/src/http.ts
CHANGED
|
@@ -148,7 +148,7 @@ export class FetchHttpClient {
|
|
|
148
148
|
maybeReportUploadProgress(data, config.onUploadProgress);
|
|
149
149
|
|
|
150
150
|
const requestRecord: HttpRequestRecord = {
|
|
151
|
-
headers:
|
|
151
|
+
headers: headersToSafeRecord(headers),
|
|
152
152
|
method,
|
|
153
153
|
url,
|
|
154
154
|
};
|
|
@@ -393,6 +393,21 @@ function headersToRecord(headers: Headers): Record<string, string> {
|
|
|
393
393
|
return out;
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
function headersToSafeRecord(headers: Headers): Record<string, string> {
|
|
397
|
+
const sensitive = new Set([
|
|
398
|
+
"authorization",
|
|
399
|
+
"cookie",
|
|
400
|
+
"proxy-authorization",
|
|
401
|
+
"x-api-key",
|
|
402
|
+
"x-device-token",
|
|
403
|
+
]);
|
|
404
|
+
const out: Record<string, string> = {};
|
|
405
|
+
headers.forEach((value, key) => {
|
|
406
|
+
out[key] = sensitive.has(key.toLowerCase()) ? "[REDACTED]" : value;
|
|
407
|
+
});
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
396
411
|
function isFormDataValue(value: unknown): value is FormData {
|
|
397
412
|
return typeof FormData !== "undefined" && value instanceof FormData;
|
|
398
413
|
}
|
package/src/keystore/node.ts
CHANGED
|
@@ -15,7 +15,7 @@ import * as path from "node:path";
|
|
|
15
15
|
* Stores credentials as encrypted files on disk using XUtils.encryptKeyData.
|
|
16
16
|
* Node-only — imports node:fs.
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
18
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
19
19
|
|
|
20
20
|
export class NodeKeyStore implements KeyStore {
|
|
21
21
|
private readonly dir: string;
|
|
@@ -65,16 +65,18 @@ export class NodeKeyStore implements KeyStore {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
async save(creds: StoredCredentials): Promise<void> {
|
|
68
|
-
const data = JSON.stringify(creds);
|
|
69
|
-
const encrypted =
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
const data = XUtils.encodeHex(XUtils.decodeUTF8(JSON.stringify(creds)));
|
|
69
|
+
const encrypted = await XUtils.encryptKeyDataAsync(
|
|
70
|
+
this.passphrase,
|
|
71
|
+
data,
|
|
72
|
+
);
|
|
73
|
+
const filePath = this.filePath(creds.username);
|
|
74
|
+
fs.writeFileSync(filePath, encrypted, { mode: 0o600 });
|
|
75
|
+
fs.chmodSync(filePath, 0o600);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
private filePath(username: string): string {
|
|
77
|
-
return path.join(this.dir, `${username}.vex`);
|
|
79
|
+
return path.join(this.dir, `${encodeURIComponent(username)}.vex`);
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
private async readFile(
|
|
@@ -82,17 +84,13 @@ export class NodeKeyStore implements KeyStore {
|
|
|
82
84
|
): Promise<null | StoredCredentials> {
|
|
83
85
|
try {
|
|
84
86
|
const data = fs.readFileSync(filePath);
|
|
85
|
-
const decrypted =
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
new Uint8Array(data),
|
|
93
|
-
this.passphrase,
|
|
94
|
-
);
|
|
95
|
-
const parsed: unknown = JSON.parse(decrypted);
|
|
87
|
+
const decrypted = await XUtils.decryptKeyDataAsync(
|
|
88
|
+
new Uint8Array(data),
|
|
89
|
+
this.passphrase,
|
|
90
|
+
);
|
|
91
|
+
const parsed: unknown = JSON.parse(
|
|
92
|
+
XUtils.encodeUTF8(XUtils.decodeHex(decrypted)),
|
|
93
|
+
);
|
|
96
94
|
if (isStoredCredentials(parsed)) {
|
|
97
95
|
return parsed;
|
|
98
96
|
}
|