@vex-chat/libvex 8.0.0 → 9.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 +54 -21
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +292 -164
- 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 +139 -113
- 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 +476 -219
- package/src/__tests__/harness/shared-suite.ts +110 -11
- package/src/__tests__/http.test.ts +22 -0
- package/src/__tests__/multi-device-delivery.test.ts +147 -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 +117 -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 +162 -139
- 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
|
|
@@ -366,6 +412,25 @@ export function platformSuite(
|
|
|
366
412
|
const byID = await client.servers.retrieveByID(server.serverID);
|
|
367
413
|
expect(byID?.serverID).toBe(server.serverID);
|
|
368
414
|
|
|
415
|
+
const renamed = await client.servers.update(
|
|
416
|
+
server.serverID,
|
|
417
|
+
"Renamed Server",
|
|
418
|
+
);
|
|
419
|
+
expect(renamed.name).toBe("Renamed Server");
|
|
420
|
+
|
|
421
|
+
const withIcon = await client.servers.setIcon(
|
|
422
|
+
server.serverID,
|
|
423
|
+
testImage,
|
|
424
|
+
);
|
|
425
|
+
expect(withIcon.icon).toEqual(expect.any(String));
|
|
426
|
+
expect(client.servers.iconURL(withIcon.icon!)).toContain(
|
|
427
|
+
`/server-icon/${withIcon.icon!}`,
|
|
428
|
+
);
|
|
429
|
+
const withoutIcon = await client.servers.removeIcon(
|
|
430
|
+
server.serverID,
|
|
431
|
+
);
|
|
432
|
+
expect(withoutIcon.icon).toBeUndefined();
|
|
433
|
+
|
|
369
434
|
await client.servers.delete(server.serverID);
|
|
370
435
|
const afterDelete = await client.servers.retrieve();
|
|
371
436
|
expect(
|
|
@@ -383,6 +448,12 @@ export function platformSuite(
|
|
|
383
448
|
const byID = await client.channels.retrieveByID(channel.channelID);
|
|
384
449
|
expect(byID?.channelID).toBe(channel.channelID);
|
|
385
450
|
|
|
451
|
+
const renamed = await client.channels.update(
|
|
452
|
+
channel.channelID,
|
|
453
|
+
"Renamed Channel",
|
|
454
|
+
);
|
|
455
|
+
expect(renamed.name).toBe("Renamed Channel");
|
|
456
|
+
|
|
386
457
|
await client.channels.delete(channel.channelID);
|
|
387
458
|
const channels = await client.channels.retrieve(server.serverID);
|
|
388
459
|
expect(
|
|
@@ -390,6 +461,9 @@ export function platformSuite(
|
|
|
390
461
|
).toBe(false);
|
|
391
462
|
// Default channel still exists
|
|
392
463
|
expect(channels.length).toBe(1);
|
|
464
|
+
await expect(
|
|
465
|
+
client.channels.delete(channels[0]!.channelID),
|
|
466
|
+
).rejects.toThrow();
|
|
393
467
|
|
|
394
468
|
await client.servers.delete(server.serverID);
|
|
395
469
|
});
|
|
@@ -504,6 +578,31 @@ export function platformSuite(
|
|
|
504
578
|
});
|
|
505
579
|
}
|
|
506
580
|
});
|
|
581
|
+
|
|
582
|
+
test("changes the account password with current-password proof", async () => {
|
|
583
|
+
const replacementPassword = "updated platform test password";
|
|
584
|
+
|
|
585
|
+
await expect(
|
|
586
|
+
client.me.changePassword(
|
|
587
|
+
"incorrect platform test password",
|
|
588
|
+
replacementPassword,
|
|
589
|
+
),
|
|
590
|
+
).rejects.toThrow();
|
|
591
|
+
await client.me.changePassword(password, replacementPassword);
|
|
592
|
+
await client.logout();
|
|
593
|
+
|
|
594
|
+
const oldLogin = await client.login(username, password);
|
|
595
|
+
expect(oldLogin.ok).toBe(false);
|
|
596
|
+
const newLogin = await client.login(username, replacementPassword);
|
|
597
|
+
expect(newLogin.ok).toBe(true);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("logout clears local authentication", async () => {
|
|
601
|
+
await client.logout();
|
|
602
|
+
await expect(client.connect()).rejects.toThrow(
|
|
603
|
+
"No token — call login() or loginWithDeviceKey() first.",
|
|
604
|
+
);
|
|
605
|
+
});
|
|
507
606
|
});
|
|
508
607
|
}
|
|
509
608
|
|
|
@@ -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,147 @@
|
|
|
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 { Device, User } from "@vex-chat/types";
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { Client } from "../Client.js";
|
|
12
|
+
|
|
13
|
+
type SendGroupMessage = (
|
|
14
|
+
this: unknown,
|
|
15
|
+
channelID: string,
|
|
16
|
+
message: string,
|
|
17
|
+
) => Promise<void>;
|
|
18
|
+
|
|
19
|
+
type SendMessage = (
|
|
20
|
+
this: unknown,
|
|
21
|
+
userID: string,
|
|
22
|
+
message: string,
|
|
23
|
+
) => Promise<void>;
|
|
24
|
+
|
|
25
|
+
const now = "2026-07-14T00:00:00.000Z";
|
|
26
|
+
|
|
27
|
+
function device(deviceID: string, owner: string): Device {
|
|
28
|
+
return {
|
|
29
|
+
deleted: false,
|
|
30
|
+
deviceID,
|
|
31
|
+
lastLogin: now,
|
|
32
|
+
name: deviceID,
|
|
33
|
+
owner,
|
|
34
|
+
signKey: `${deviceID}-sign-key`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function user(userID: string, username: string): User {
|
|
39
|
+
return { lastSeen: now, userID, username };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("multi-device delivery", () => {
|
|
43
|
+
it("succeeds when one direct-message recipient device is unavailable", async () => {
|
|
44
|
+
const sender = user("user-a", "alice");
|
|
45
|
+
const recipient = user("user-b", "bob");
|
|
46
|
+
const unavailable = device("device-b-1", recipient.userID);
|
|
47
|
+
const available = device("device-b-2", recipient.userID);
|
|
48
|
+
const sendMailWithRecovery = vi.fn((target: Device) =>
|
|
49
|
+
target === unavailable
|
|
50
|
+
? Promise.reject(new Error("Device is unavailable."))
|
|
51
|
+
: Promise.resolve(null),
|
|
52
|
+
);
|
|
53
|
+
const fakeClient = {
|
|
54
|
+
fetchUser: vi.fn(() => Promise.resolve([recipient, null])),
|
|
55
|
+
fetchUserDeviceListOnce: vi.fn(() =>
|
|
56
|
+
Promise.resolve([unavailable, available]),
|
|
57
|
+
),
|
|
58
|
+
fetchUserDeviceListWithBackoff: vi.fn(() =>
|
|
59
|
+
Promise.resolve([unavailable, available]),
|
|
60
|
+
),
|
|
61
|
+
getUser: () => sender,
|
|
62
|
+
sendMailWithRecovery,
|
|
63
|
+
};
|
|
64
|
+
const sendMessage = Reflect.get(
|
|
65
|
+
Client.prototype,
|
|
66
|
+
"sendMessage",
|
|
67
|
+
) as SendMessage;
|
|
68
|
+
|
|
69
|
+
await expect(
|
|
70
|
+
sendMessage.call(fakeClient, recipient.userID, "hello"),
|
|
71
|
+
).resolves.toBeUndefined();
|
|
72
|
+
expect(sendMailWithRecovery).toHaveBeenCalledTimes(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("succeeds when another device for each group recipient receives the message", async () => {
|
|
76
|
+
const sender = user("user-a", "alice");
|
|
77
|
+
const recipient = user("user-b", "bob");
|
|
78
|
+
const ownDevice = device("device-a-1", sender.userID);
|
|
79
|
+
const unavailable = device("device-b-1", recipient.userID);
|
|
80
|
+
const available = device("device-b-2", recipient.userID);
|
|
81
|
+
const sendMailWithRecovery = vi.fn((target: Device) =>
|
|
82
|
+
target === unavailable
|
|
83
|
+
? Promise.reject(new Error("Device is unavailable."))
|
|
84
|
+
: Promise.resolve(null),
|
|
85
|
+
);
|
|
86
|
+
const fakeClient = {
|
|
87
|
+
fetchUserDeviceListWithBackoff: vi.fn(() =>
|
|
88
|
+
Promise.resolve([ownDevice]),
|
|
89
|
+
),
|
|
90
|
+
getMultiUserDeviceList: vi.fn(() =>
|
|
91
|
+
Promise.resolve([unavailable, available]),
|
|
92
|
+
),
|
|
93
|
+
getUser: () => sender,
|
|
94
|
+
getUserList: vi.fn(() => Promise.resolve([sender, recipient])),
|
|
95
|
+
sendMailWithRecovery,
|
|
96
|
+
userRecords: {} as Record<string, User>,
|
|
97
|
+
};
|
|
98
|
+
const sendGroupMessage = Reflect.get(
|
|
99
|
+
Client.prototype,
|
|
100
|
+
"sendGroupMessage",
|
|
101
|
+
) as SendGroupMessage;
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
sendGroupMessage.call(
|
|
105
|
+
fakeClient,
|
|
106
|
+
"1b0a66e2-8275-4f8b-84d5-cb7309d41410",
|
|
107
|
+
"hello channel",
|
|
108
|
+
),
|
|
109
|
+
).resolves.toBeUndefined();
|
|
110
|
+
expect(sendMailWithRecovery).toHaveBeenCalledTimes(3);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("still fails a group send when a recipient has no successful device delivery", async () => {
|
|
114
|
+
const sender = user("user-a", "alice");
|
|
115
|
+
const recipient = user("user-b", "bob");
|
|
116
|
+
const ownDevice = device("device-a-1", sender.userID);
|
|
117
|
+
const recipientDevice = device("device-b-1", recipient.userID);
|
|
118
|
+
const fakeClient = {
|
|
119
|
+
fetchUserDeviceListWithBackoff: vi.fn(() =>
|
|
120
|
+
Promise.resolve([ownDevice]),
|
|
121
|
+
),
|
|
122
|
+
getMultiUserDeviceList: vi.fn(() =>
|
|
123
|
+
Promise.resolve([recipientDevice]),
|
|
124
|
+
),
|
|
125
|
+
getUser: () => sender,
|
|
126
|
+
getUserList: vi.fn(() => Promise.resolve([sender, recipient])),
|
|
127
|
+
sendMailWithRecovery: vi.fn((target: Device) =>
|
|
128
|
+
target === recipientDevice
|
|
129
|
+
? Promise.reject(new Error("Recipient is unavailable."))
|
|
130
|
+
: Promise.resolve(null),
|
|
131
|
+
),
|
|
132
|
+
userRecords: {} as Record<string, User>,
|
|
133
|
+
};
|
|
134
|
+
const sendGroupMessage = Reflect.get(
|
|
135
|
+
Client.prototype,
|
|
136
|
+
"sendGroupMessage",
|
|
137
|
+
) as SendGroupMessage;
|
|
138
|
+
|
|
139
|
+
await expect(
|
|
140
|
+
sendGroupMessage.call(
|
|
141
|
+
fakeClient,
|
|
142
|
+
"1b0a66e2-8275-4f8b-84d5-cb7309d41410",
|
|
143
|
+
"hello channel",
|
|
144
|
+
),
|
|
145
|
+
).rejects.toThrow("Recipient is unavailable.");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -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,96 @@ 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
|
+
});
|
|
141
|
+
|
|
142
|
+
it("atomically updates an existing Double Ratchet session", async () => {
|
|
143
|
+
const { storage } = makeStorage();
|
|
144
|
+
try {
|
|
145
|
+
await storage.init();
|
|
146
|
+
const session = makeSession({ Ns: 1 });
|
|
147
|
+
await storage.saveSession(session);
|
|
148
|
+
|
|
149
|
+
await storage.saveSession({
|
|
150
|
+
...session,
|
|
151
|
+
CKs: "77".repeat(32),
|
|
152
|
+
lastUsed: "2026-06-02T00:00:00.000Z",
|
|
153
|
+
Ns: 2,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const rows = await storage.getAllSessions();
|
|
157
|
+
expect(rows).toHaveLength(1);
|
|
158
|
+
expect(rows[0]).toMatchObject({
|
|
159
|
+
CKs: "77".repeat(32),
|
|
160
|
+
lastUsed: "2026-06-02T00:00:00.000Z",
|
|
161
|
+
Ns: 2,
|
|
162
|
+
sessionID: session.sessionID,
|
|
163
|
+
});
|
|
164
|
+
} finally {
|
|
165
|
+
await storage.close();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
77
168
|
});
|
|
78
169
|
|
|
79
170
|
function makeMessage(overrides: Partial<Message>): Message {
|
|
@@ -94,6 +185,31 @@ function makeMessage(overrides: Partial<Message>): Message {
|
|
|
94
185
|
};
|
|
95
186
|
}
|
|
96
187
|
|
|
188
|
+
function makeSession(overrides: Partial<SessionSQL>): SessionSQL {
|
|
189
|
+
return {
|
|
190
|
+
CKr: null,
|
|
191
|
+
CKs: null,
|
|
192
|
+
deviceID: "peer-device",
|
|
193
|
+
DHr: null,
|
|
194
|
+
DHsPrivate: "11".repeat(32),
|
|
195
|
+
DHsPublic: "22".repeat(32),
|
|
196
|
+
fingerprint: "33".repeat(32),
|
|
197
|
+
lastUsed: "2026-06-01T00:00:00.000Z",
|
|
198
|
+
mode: "initiator",
|
|
199
|
+
Nr: 0,
|
|
200
|
+
Ns: 0,
|
|
201
|
+
PN: 0,
|
|
202
|
+
publicKey: "44".repeat(32),
|
|
203
|
+
RK: "55".repeat(32),
|
|
204
|
+
sessionID: "session-id",
|
|
205
|
+
SK: "66".repeat(32),
|
|
206
|
+
skippedKeys: "{}",
|
|
207
|
+
userID: "peer-user",
|
|
208
|
+
verified: false,
|
|
209
|
+
...overrides,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
97
213
|
function makeStorage(): {
|
|
98
214
|
db: Kysely<ClientDatabase>;
|
|
99
215
|
storage: SqliteStorage;
|
|
@@ -112,7 +228,7 @@ function makeStorage(): {
|
|
|
112
228
|
async function messageRow(db: Kysely<ClientDatabase>, mailID: string) {
|
|
113
229
|
return await db
|
|
114
230
|
.selectFrom("messages")
|
|
115
|
-
.select(["decrypted", "extra", "message"])
|
|
231
|
+
.select(["decrypted", "extra", "message", "nonce"])
|
|
116
232
|
.where("mailID", "=", mailID)
|
|
117
233
|
.executeTakeFirst();
|
|
118
234
|
}
|