@vex-chat/spire 3.0.1 → 4.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/README.md +9 -8
- package/dist/Database.d.ts +20 -11
- package/dist/Database.js +229 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -2
- package/dist/Spire.js +155 -72
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +0 -2
- package/dist/migrations/2026-04-06_initial-schema.js +4 -5
- package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
- package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
- package/dist/migrations/2026-07-14_query-indexes.js +31 -0
- package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
- package/dist/server/avatar.js +33 -6
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +109 -11
- package/dist/server/cliPasskeyPage.js.map +1 -1
- package/dist/server/errors.js +8 -0
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.js +35 -12
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.js +319 -56
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +367 -29
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.js +1 -0
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/password.d.ts +7 -0
- package/dist/server/password.js +58 -0
- package/dist/server/password.js.map +1 -0
- package/dist/server/permissions.d.ts +1 -0
- package/dist/server/permissions.js +11 -2
- package/dist/server/permissions.js.map +1 -1
- package/dist/server/rateLimit.d.ts +11 -0
- package/dist/server/rateLimit.js +43 -4
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/serverIcon.d.ts +10 -0
- package/dist/server/serverIcon.js +158 -0
- package/dist/server/serverIcon.js.map +1 -0
- package/dist/server/user.d.ts +1 -1
- package/dist/server/user.js +40 -9
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +3 -4
- package/dist/types/express.js.map +1 -1
- package/dist/utils/authJwt.d.ts +8 -0
- package/dist/utils/authJwt.js +25 -0
- package/dist/utils/authJwt.js.map +1 -0
- package/dist/utils/loadEnv.js +4 -0
- package/dist/utils/loadEnv.js.map +1 -1
- package/dist/utils/preKeySignature.d.ts +1 -1
- package/dist/utils/preKeySignature.js +7 -11
- package/dist/utils/preKeySignature.js.map +1 -1
- package/package.json +7 -7
- package/src/Database.ts +294 -152
- package/src/Spire.ts +412 -285
- package/src/__tests__/Database.spec.ts +126 -3
- package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
- package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
- package/src/__tests__/connectAuth.spec.ts +146 -4
- package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
- package/src/__tests__/passkeyDevices.spec.ts +94 -0
- package/src/__tests__/passkeys.spec.ts +7 -2
- package/src/__tests__/password.spec.ts +223 -0
- package/src/__tests__/permissions.spec.ts +50 -0
- package/src/__tests__/preKeySignature.spec.ts +20 -3
- package/src/__tests__/serverManagement.spec.ts +262 -0
- package/src/db/schema.ts +0 -2
- package/src/migrations/2026-04-06_initial-schema.ts +4 -5
- package/src/migrations/2026-07-14_query-indexes.ts +34 -0
- package/src/server/avatar.ts +32 -6
- package/src/server/cliPasskeyPage.ts +109 -11
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +494 -139
- package/src/server/passkey.ts +531 -31
- package/src/server/passkeyDevices.ts +1 -0
- package/src/server/password.ts +96 -0
- package/src/server/permissions.ts +20 -2
- package/src/server/rateLimit.ts +47 -4
- package/src/server/serverIcon.ts +199 -0
- package/src/server/user.ts +44 -9
- package/src/types/express.ts +3 -4
- package/src/utils/authJwt.ts +34 -0
- package/src/utils/loadEnv.ts +4 -0
- package/src/utils/preKeySignature.ts +12 -15
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
|
@@ -11,14 +11,18 @@ import type { Server } from "node:http";
|
|
|
11
11
|
import express from "express";
|
|
12
12
|
|
|
13
13
|
import { xSignAsync, xSignKeyPair, XUtils } from "@vex-chat/crypto";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
MAX_FILE_UPLOAD_BASE64_LENGTH,
|
|
16
|
+
MAX_FILE_UPLOAD_BYTES,
|
|
17
|
+
MAX_FILE_UPLOAD_ENCODED_BODY_BYTES,
|
|
18
|
+
TokenScopes,
|
|
19
|
+
} from "@vex-chat/types";
|
|
15
20
|
|
|
16
|
-
import jwt from "jsonwebtoken";
|
|
17
21
|
import { parse as uuidParse } from "uuid";
|
|
18
22
|
import { afterEach, describe, expect, it } from "vitest";
|
|
19
23
|
|
|
20
24
|
import { initApp } from "../server/index.ts";
|
|
21
|
-
import {
|
|
25
|
+
import { signAuthJwt } from "../utils/authJwt.ts";
|
|
22
26
|
import { msgpack } from "../utils/msgpack.ts";
|
|
23
27
|
|
|
24
28
|
const originalJwtSecret = process.env["JWT_SECRET"];
|
|
@@ -74,7 +78,7 @@ describe("device connect auth", () => {
|
|
|
74
78
|
Uint8Array.from(uuidParse(connectToken)),
|
|
75
79
|
signKeys.secretKey,
|
|
76
80
|
);
|
|
77
|
-
const token =
|
|
81
|
+
const token = signAuthJwt({ scope: "user", user }, "5m");
|
|
78
82
|
|
|
79
83
|
const res = await fetch(
|
|
80
84
|
`http://127.0.0.1:${String(address.port)}/device/${device.deviceID}/connect`,
|
|
@@ -97,6 +101,144 @@ describe("device connect auth", () => {
|
|
|
97
101
|
await close(server);
|
|
98
102
|
}
|
|
99
103
|
});
|
|
104
|
+
|
|
105
|
+
it("does not let a passkey-scoped token enter regular account routes", async () => {
|
|
106
|
+
process.env["JWT_SECRET"] = "test-jwt-secret";
|
|
107
|
+
const db = {
|
|
108
|
+
retrievePasskeyInternal: () => Promise.resolve(null),
|
|
109
|
+
} as unknown as Database;
|
|
110
|
+
const app = express();
|
|
111
|
+
initApp(
|
|
112
|
+
app,
|
|
113
|
+
db,
|
|
114
|
+
() => false,
|
|
115
|
+
xSignKeyPair(),
|
|
116
|
+
() => {},
|
|
117
|
+
);
|
|
118
|
+
const server = await listen(app);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const address = server.address();
|
|
122
|
+
if (!address || typeof address === "string") {
|
|
123
|
+
throw new Error("Expected TCP listener.");
|
|
124
|
+
}
|
|
125
|
+
const token = signAuthJwt(
|
|
126
|
+
{
|
|
127
|
+
passkey: { passkeyID: "passkey-a" },
|
|
128
|
+
scope: "passkey",
|
|
129
|
+
user,
|
|
130
|
+
},
|
|
131
|
+
"5m",
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const res = await fetch(
|
|
135
|
+
`http://127.0.0.1:${String(address.port)}/server/server-a`,
|
|
136
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(res.status).toBe(401);
|
|
140
|
+
} finally {
|
|
141
|
+
await close(server);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("limits the default development CORS policy to local app origins", async () => {
|
|
146
|
+
const originalCorsOrigins = process.env["CORS_ORIGINS"];
|
|
147
|
+
delete process.env["CORS_ORIGINS"];
|
|
148
|
+
const app = express();
|
|
149
|
+
initApp(
|
|
150
|
+
app,
|
|
151
|
+
{} as Database,
|
|
152
|
+
() => false,
|
|
153
|
+
xSignKeyPair(),
|
|
154
|
+
() => {},
|
|
155
|
+
);
|
|
156
|
+
const server = await listen(app);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const address = server.address();
|
|
160
|
+
if (!address || typeof address === "string") {
|
|
161
|
+
throw new Error("Expected TCP listener.");
|
|
162
|
+
}
|
|
163
|
+
const url = `http://127.0.0.1:${String(address.port)}/server/server-a`;
|
|
164
|
+
const preflightHeaders = {
|
|
165
|
+
"Access-Control-Request-Method": "GET",
|
|
166
|
+
};
|
|
167
|
+
const blocked = await fetch(url, {
|
|
168
|
+
headers: {
|
|
169
|
+
...preflightHeaders,
|
|
170
|
+
Origin: "https://attacker.example",
|
|
171
|
+
},
|
|
172
|
+
method: "OPTIONS",
|
|
173
|
+
});
|
|
174
|
+
const allowed = await fetch(url, {
|
|
175
|
+
headers: {
|
|
176
|
+
...preflightHeaders,
|
|
177
|
+
Origin: "http://localhost:5180",
|
|
178
|
+
},
|
|
179
|
+
method: "OPTIONS",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(
|
|
183
|
+
blocked.headers.get("access-control-allow-origin"),
|
|
184
|
+
).toBeNull();
|
|
185
|
+
expect(allowed.headers.get("access-control-allow-origin")).toBe(
|
|
186
|
+
"http://localhost:5180",
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
if (originalCorsOrigins === undefined) {
|
|
190
|
+
delete process.env["CORS_ORIGINS"];
|
|
191
|
+
} else {
|
|
192
|
+
process.env["CORS_ORIGINS"] = originalCorsOrigins;
|
|
193
|
+
}
|
|
194
|
+
await close(server);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("accepts a fallback upload body above the default parser limit", async () => {
|
|
199
|
+
expect(MAX_FILE_UPLOAD_BASE64_LENGTH).toBe(
|
|
200
|
+
4 * Math.ceil(MAX_FILE_UPLOAD_BYTES / 3),
|
|
201
|
+
);
|
|
202
|
+
expect(MAX_FILE_UPLOAD_ENCODED_BODY_BYTES).toBeGreaterThan(
|
|
203
|
+
MAX_FILE_UPLOAD_BASE64_LENGTH,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const encodedFileLength = 20 * 1024 * 1024 + 1;
|
|
207
|
+
expect(encodedFileLength).toBeLessThan(MAX_FILE_UPLOAD_BASE64_LENGTH);
|
|
208
|
+
const app = express();
|
|
209
|
+
initApp(
|
|
210
|
+
app,
|
|
211
|
+
{} as Database,
|
|
212
|
+
() => false,
|
|
213
|
+
xSignKeyPair(),
|
|
214
|
+
() => {},
|
|
215
|
+
);
|
|
216
|
+
const server = await listen(app);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const address = server.address();
|
|
220
|
+
if (!address || typeof address === "string") {
|
|
221
|
+
throw new Error("Expected TCP listener.");
|
|
222
|
+
}
|
|
223
|
+
const response = await fetch(
|
|
224
|
+
`http://127.0.0.1:${String(address.port)}/file/json`,
|
|
225
|
+
{
|
|
226
|
+
body: msgpack.encode({
|
|
227
|
+
file: "A".repeat(encodedFileLength),
|
|
228
|
+
nonce: "a".repeat(48),
|
|
229
|
+
owner: "device-a",
|
|
230
|
+
}),
|
|
231
|
+
headers: { "Content-Type": "application/msgpack" },
|
|
232
|
+
method: "POST",
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Parsing succeeded; authentication is the next middleware.
|
|
237
|
+
expect(response.status).toBe(401);
|
|
238
|
+
} finally {
|
|
239
|
+
await close(server);
|
|
240
|
+
}
|
|
241
|
+
}, 15_000);
|
|
100
242
|
});
|
|
101
243
|
|
|
102
244
|
function close(server: Server): Promise<void> {
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* Commercial licenses available at vex.wtf
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { Database } from "../Database.ts";
|
|
7
8
|
import type { Device, User } from "@vex-chat/types";
|
|
8
9
|
import type express from "express";
|
|
9
10
|
|
|
10
|
-
import jwt from "jsonwebtoken";
|
|
11
11
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
12
|
|
|
13
|
-
import { createCheckDevice } from "../server/index.ts";
|
|
13
|
+
import { createCheckDevice, createCheckPasskey } from "../server/index.ts";
|
|
14
|
+
import { signAuthJwt } from "../utils/authJwt.ts";
|
|
14
15
|
|
|
15
16
|
const jwtSecret = "test-jwt-secret";
|
|
16
17
|
|
|
@@ -29,6 +30,8 @@ const device: Device = {
|
|
|
29
30
|
signKey: "sign-key-a",
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
const passkeyID = "passkey-a";
|
|
34
|
+
|
|
32
35
|
function makeReq(token: string, reqUser: undefined | User = user) {
|
|
33
36
|
return {
|
|
34
37
|
headers: { "x-device-token": token },
|
|
@@ -37,7 +40,7 @@ function makeReq(token: string, reqUser: undefined | User = user) {
|
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
function makeToken(tokenDevice: Device = device): string {
|
|
40
|
-
return
|
|
43
|
+
return signAuthJwt({ device: tokenDevice, scope: "device" }, "5m");
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
describe("createCheckDevice", () => {
|
|
@@ -102,3 +105,54 @@ describe("createCheckDevice", () => {
|
|
|
102
105
|
expect(req.device).toBeUndefined();
|
|
103
106
|
});
|
|
104
107
|
});
|
|
108
|
+
|
|
109
|
+
describe("createCheckPasskey", () => {
|
|
110
|
+
function makePasskeyReq(reqUser: User = user) {
|
|
111
|
+
return {
|
|
112
|
+
bearerToken: "passkey-token",
|
|
113
|
+
headers: {},
|
|
114
|
+
passkey: { passkeyID },
|
|
115
|
+
user: reqUser,
|
|
116
|
+
} as unknown as express.Request;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function makePasskeyDb(row: null | { userID: string }) {
|
|
120
|
+
return {
|
|
121
|
+
retrievePasskeyInternal: vi.fn(() => Promise.resolve(row)),
|
|
122
|
+
} as unknown as Pick<Database, "retrievePasskeyInternal">;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it("keeps a passkey session while its credential remains bound", async () => {
|
|
126
|
+
const db = makePasskeyDb({ userID: user.userID });
|
|
127
|
+
const req = makePasskeyReq();
|
|
128
|
+
const next = vi.fn();
|
|
129
|
+
|
|
130
|
+
await createCheckPasskey(db)(req, {} as express.Response, next);
|
|
131
|
+
|
|
132
|
+
expect(req.passkey).toEqual({ passkeyID });
|
|
133
|
+
expect(req.user).toEqual(user);
|
|
134
|
+
expect(db.retrievePasskeyInternal).toHaveBeenCalledWith(passkeyID);
|
|
135
|
+
expect(next).toHaveBeenCalledOnce();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("revokes a passkey session as soon as its credential is removed", async () => {
|
|
139
|
+
const db = makePasskeyDb(null);
|
|
140
|
+
const req = makePasskeyReq();
|
|
141
|
+
|
|
142
|
+
await createCheckPasskey(db)(req, {} as express.Response, vi.fn());
|
|
143
|
+
|
|
144
|
+
expect(req.bearerToken).toBeUndefined();
|
|
145
|
+
expect(req.passkey).toBeUndefined();
|
|
146
|
+
expect(req.user).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("rejects a passkey credential bound to a different account", async () => {
|
|
150
|
+
const db = makePasskeyDb({ userID: "user-b" });
|
|
151
|
+
const req = makePasskeyReq();
|
|
152
|
+
|
|
153
|
+
await createCheckPasskey(db)(req, {} as express.Response, vi.fn());
|
|
154
|
+
|
|
155
|
+
expect(req.passkey).toBeUndefined();
|
|
156
|
+
expect(req.user).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { Database } from "../Database.ts";
|
|
8
|
+
import type { Device, User } from "@vex-chat/types";
|
|
9
|
+
import type { Server } from "node:http";
|
|
10
|
+
|
|
11
|
+
import express from "express";
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { getPasskeyDeviceRouter } from "../server/passkeyDevices.ts";
|
|
16
|
+
|
|
17
|
+
const user: User = {
|
|
18
|
+
lastSeen: new Date(0).toISOString(),
|
|
19
|
+
userID: "user-a",
|
|
20
|
+
username: "alice",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const device: Device = {
|
|
24
|
+
deleted: false,
|
|
25
|
+
deviceID: "device-a",
|
|
26
|
+
lastLogin: new Date(0).toISOString(),
|
|
27
|
+
name: "desktop",
|
|
28
|
+
owner: user.userID,
|
|
29
|
+
signKey: "a".repeat(64),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("passkey device administration", () => {
|
|
33
|
+
it("disconnects a device immediately after deleting it", async () => {
|
|
34
|
+
const deleteDevice = vi.fn(() => Promise.resolve());
|
|
35
|
+
const db = {
|
|
36
|
+
deleteDevice,
|
|
37
|
+
retrieveDevice: vi.fn((deviceID: string) =>
|
|
38
|
+
Promise.resolve(deviceID === device.deviceID ? device : null),
|
|
39
|
+
),
|
|
40
|
+
} as unknown as Database;
|
|
41
|
+
const disconnectDevices = vi.fn();
|
|
42
|
+
const notify = vi.fn();
|
|
43
|
+
const app = express();
|
|
44
|
+
app.use((req, _res, next) => {
|
|
45
|
+
req.passkey = { passkeyID: "passkey-a" };
|
|
46
|
+
req.user = user;
|
|
47
|
+
next();
|
|
48
|
+
});
|
|
49
|
+
app.use(getPasskeyDeviceRouter(db, notify, disconnectDevices));
|
|
50
|
+
const server = await listen(app);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const address = server.address();
|
|
54
|
+
if (!address || typeof address === "string") {
|
|
55
|
+
throw new Error("Expected TCP listener.");
|
|
56
|
+
}
|
|
57
|
+
const response = await fetch(
|
|
58
|
+
`http://127.0.0.1:${String(address.port)}/user/${user.userID}/passkey/devices/${device.deviceID}`,
|
|
59
|
+
{ method: "DELETE" },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(response.status).toBe(200);
|
|
63
|
+
expect(deleteDevice).toHaveBeenCalledWith(device.deviceID);
|
|
64
|
+
expect(disconnectDevices).toHaveBeenCalledWith([device.deviceID]);
|
|
65
|
+
expect(notify).toHaveBeenCalledWith(
|
|
66
|
+
user.userID,
|
|
67
|
+
"deviceListChanged",
|
|
68
|
+
expect.any(String),
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
await close(server);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function close(server: Server): Promise<void> {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
server.close((error) => {
|
|
79
|
+
if (error) {
|
|
80
|
+
reject(error);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
resolve();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function listen(app: express.Application): Promise<Server> {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const server = app.listen(0, "127.0.0.1", () => {
|
|
91
|
+
resolve(server);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -180,7 +180,7 @@ describe("Database passkeys", () => {
|
|
|
180
180
|
|
|
181
181
|
describe("markPasskeyUsed", () => {
|
|
182
182
|
it("bumps the signature counter and lastUsedAt timestamp", async () => {
|
|
183
|
-
expect.assertions(
|
|
183
|
+
expect.assertions(5);
|
|
184
184
|
await withDb(async (db) => {
|
|
185
185
|
const created = await db.createPasskey(
|
|
186
186
|
userID,
|
|
@@ -191,13 +191,18 @@ describe("Database passkeys", () => {
|
|
|
191
191
|
samplePasskey.transports,
|
|
192
192
|
);
|
|
193
193
|
|
|
194
|
-
await
|
|
194
|
+
await expect(
|
|
195
|
+
db.markPasskeyUsed(created.passkeyID, 0, 42),
|
|
196
|
+
).resolves.toBe(true);
|
|
195
197
|
const row = await db.retrievePasskeyInternal(created.passkeyID);
|
|
196
198
|
|
|
197
199
|
expect(row).not.toBeNull();
|
|
198
200
|
if (row === null) return;
|
|
199
201
|
expect(row.signCount).toBe(42);
|
|
200
202
|
expect(row.lastUsedAt).not.toBeNull();
|
|
203
|
+
await expect(
|
|
204
|
+
db.markPasskeyUsed(created.passkeyID, 0, 43),
|
|
205
|
+
).resolves.toBe(false);
|
|
201
206
|
});
|
|
202
207
|
});
|
|
203
208
|
});
|
|
@@ -0,0 +1,223 @@
|
|
|
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 { Database } from "../Database.ts";
|
|
8
|
+
import type { AddressInfo } from "node:net";
|
|
9
|
+
|
|
10
|
+
import express from "express";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
afterAll,
|
|
14
|
+
afterEach,
|
|
15
|
+
beforeAll,
|
|
16
|
+
describe,
|
|
17
|
+
expect,
|
|
18
|
+
it,
|
|
19
|
+
vi,
|
|
20
|
+
} from "vitest";
|
|
21
|
+
|
|
22
|
+
import { hashPasswordArgon2, verifyPassword } from "../Database.ts";
|
|
23
|
+
import { errorHandler } from "../server/errors.ts";
|
|
24
|
+
import { getPasswordRouter } from "../server/password.ts";
|
|
25
|
+
|
|
26
|
+
const userID = "4e67b90f-cbf8-44bc-8ce3-d3b248f033f1";
|
|
27
|
+
const otherUserID = "93ce482b-a0f2-4f6e-b1df-3aed61073552";
|
|
28
|
+
const passkeyID = "passkey-1";
|
|
29
|
+
const initialPassword = "This is the original password";
|
|
30
|
+
const replacementPassword = "This is the replacement password";
|
|
31
|
+
const servers: Array<{ close: () => void }> = [];
|
|
32
|
+
const originalDisableRateLimits = process.env["SPIRE_DISABLE_RATE_LIMITS"];
|
|
33
|
+
let initialPasswordHash = "";
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
process.env["SPIRE_DISABLE_RATE_LIMITS"] = "true";
|
|
37
|
+
initialPasswordHash = await hashPasswordArgon2(initialPassword);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
for (const server of servers.splice(0)) server.close();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(() => {
|
|
45
|
+
if (originalDisableRateLimits === undefined) {
|
|
46
|
+
delete process.env["SPIRE_DISABLE_RATE_LIMITS"];
|
|
47
|
+
} else {
|
|
48
|
+
process.env["SPIRE_DISABLE_RATE_LIMITS"] = originalDisableRateLimits;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("verifyPassword", () => {
|
|
53
|
+
it.each(["not-an-argon2-hash", "pbkdf2$100000$salt$hash"])(
|
|
54
|
+
"fails closed for an unsupported stored hash: %s",
|
|
55
|
+
async (passwordHash) => {
|
|
56
|
+
await expect(
|
|
57
|
+
verifyPassword(initialPassword, { passwordHash }),
|
|
58
|
+
).resolves.toEqual({ needsRehash: false, valid: false });
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("PATCH /user/:id/password", () => {
|
|
64
|
+
it("changes a password after approved-device and current-password proof", async () => {
|
|
65
|
+
const harness = await mountPasswordRouter("device");
|
|
66
|
+
const response = await patchPassword(harness.baseUrl, userID, {
|
|
67
|
+
currentPassword: initialPassword,
|
|
68
|
+
newPassword: replacementPassword,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(response.status).toBe(204);
|
|
72
|
+
expect(harness.rehashPassword).toHaveBeenCalledOnce();
|
|
73
|
+
await expectPassword(harness.passwordHash(), replacementPassword, true);
|
|
74
|
+
await expectPassword(harness.passwordHash(), initialPassword, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("rejects missing or incorrect current-password proof", async () => {
|
|
78
|
+
const harness = await mountPasswordRouter("device");
|
|
79
|
+
const missing = await patchPassword(harness.baseUrl, userID, {
|
|
80
|
+
newPassword: replacementPassword,
|
|
81
|
+
});
|
|
82
|
+
const incorrect = await patchPassword(harness.baseUrl, userID, {
|
|
83
|
+
currentPassword: "This is definitely not the password",
|
|
84
|
+
newPassword: replacementPassword,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(missing.status).toBe(401);
|
|
88
|
+
expect(incorrect.status).toBe(401);
|
|
89
|
+
expect(harness.rehashPassword).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("resets a password after fresh passkey proof", async () => {
|
|
93
|
+
const harness = await mountPasswordRouter("passkey");
|
|
94
|
+
const response = await patchPassword(harness.baseUrl, userID, {
|
|
95
|
+
newPassword: replacementPassword,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(response.status).toBe(204);
|
|
99
|
+
await expectPassword(harness.passwordHash(), replacementPassword, true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rejects passkeys that are no longer bound to the account", async () => {
|
|
103
|
+
const harness = await mountPasswordRouter("passkey", false);
|
|
104
|
+
const response = await patchPassword(harness.baseUrl, userID, {
|
|
105
|
+
newPassword: replacementPassword,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(response.status).toBe(401);
|
|
109
|
+
expect(harness.rehashPassword).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("rejects cross-account targeting, reuse, and common passwords", async () => {
|
|
113
|
+
const harness = await mountPasswordRouter("device");
|
|
114
|
+
const crossAccount = await patchPassword(harness.baseUrl, otherUserID, {
|
|
115
|
+
currentPassword: initialPassword,
|
|
116
|
+
newPassword: replacementPassword,
|
|
117
|
+
});
|
|
118
|
+
const reused = await patchPassword(harness.baseUrl, userID, {
|
|
119
|
+
currentPassword: initialPassword,
|
|
120
|
+
newPassword: initialPassword,
|
|
121
|
+
});
|
|
122
|
+
const common = await patchPassword(harness.baseUrl, userID, {
|
|
123
|
+
currentPassword: initialPassword,
|
|
124
|
+
newPassword: "passwordpassword",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(crossAccount.status).toBe(403);
|
|
128
|
+
expect(reused.status).toBe(409);
|
|
129
|
+
expect(common.status).toBe(400);
|
|
130
|
+
expect(harness.rehashPassword).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
async function expectPassword(
|
|
135
|
+
passwordHash: string,
|
|
136
|
+
password: string,
|
|
137
|
+
expected: boolean,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const result = await verifyPassword(password, { passwordHash });
|
|
140
|
+
expect(result.valid).toBe(expected);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function mountPasswordRouter(
|
|
144
|
+
auth: "device" | "passkey",
|
|
145
|
+
passkeyExists = true,
|
|
146
|
+
): Promise<{
|
|
147
|
+
baseUrl: string;
|
|
148
|
+
passwordHash: () => string;
|
|
149
|
+
rehashPassword: ReturnType<typeof vi.fn>;
|
|
150
|
+
}> {
|
|
151
|
+
let passwordHash = initialPasswordHash;
|
|
152
|
+
const rehashPassword = vi.fn((_userID: string, nextHash: string) => {
|
|
153
|
+
passwordHash = nextHash;
|
|
154
|
+
return Promise.resolve();
|
|
155
|
+
});
|
|
156
|
+
const db = {
|
|
157
|
+
rehashPassword,
|
|
158
|
+
retrievePasskeyInternal: vi.fn(() =>
|
|
159
|
+
Promise.resolve(passkeyExists ? { passkeyID, userID } : null),
|
|
160
|
+
),
|
|
161
|
+
retrieveUser: vi.fn((identifier: string) =>
|
|
162
|
+
Promise.resolve(
|
|
163
|
+
identifier === userID
|
|
164
|
+
? {
|
|
165
|
+
lastSeen: new Date().toISOString(),
|
|
166
|
+
passwordHash,
|
|
167
|
+
userID,
|
|
168
|
+
username: "alice",
|
|
169
|
+
}
|
|
170
|
+
: null,
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
} as unknown as Database;
|
|
174
|
+
|
|
175
|
+
const app = express();
|
|
176
|
+
app.use(express.json());
|
|
177
|
+
app.use((req, _res, next) => {
|
|
178
|
+
req.user = {
|
|
179
|
+
lastSeen: new Date().toISOString(),
|
|
180
|
+
userID,
|
|
181
|
+
username: "alice",
|
|
182
|
+
};
|
|
183
|
+
if (auth === "device") {
|
|
184
|
+
req.device = {
|
|
185
|
+
deleted: false,
|
|
186
|
+
deviceID: "device-1",
|
|
187
|
+
lastLogin: new Date().toISOString(),
|
|
188
|
+
name: "Test device",
|
|
189
|
+
owner: userID,
|
|
190
|
+
signKey: "a".repeat(64),
|
|
191
|
+
};
|
|
192
|
+
} else {
|
|
193
|
+
req.passkey = { passkeyID };
|
|
194
|
+
}
|
|
195
|
+
next();
|
|
196
|
+
});
|
|
197
|
+
app.use(getPasswordRouter(db));
|
|
198
|
+
app.use(errorHandler());
|
|
199
|
+
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
const server = app.listen(0, "127.0.0.1", () => {
|
|
202
|
+
const { port } = server.address() as AddressInfo;
|
|
203
|
+
resolve({
|
|
204
|
+
baseUrl: `http://127.0.0.1:${String(port)}`,
|
|
205
|
+
passwordHash: () => passwordHash,
|
|
206
|
+
rehashPassword,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
servers.push(server);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function patchPassword(
|
|
214
|
+
baseUrl: string,
|
|
215
|
+
routeUserID: string,
|
|
216
|
+
body: { currentPassword?: string; newPassword: string },
|
|
217
|
+
): Promise<Response> {
|
|
218
|
+
return fetch(`${baseUrl}/user/${routeUserID}/password`, {
|
|
219
|
+
body: JSON.stringify(body),
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
method: "PATCH",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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 { Permission } from "@vex-chat/types";
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
canDeletePermission,
|
|
13
|
+
hasPermission,
|
|
14
|
+
userHasPermission,
|
|
15
|
+
} from "../server/permissions.ts";
|
|
16
|
+
|
|
17
|
+
function permission(
|
|
18
|
+
userID: string,
|
|
19
|
+
powerLevel: number,
|
|
20
|
+
permissionID = `${userID}-${String(powerLevel)}`,
|
|
21
|
+
): Permission {
|
|
22
|
+
return {
|
|
23
|
+
permissionID,
|
|
24
|
+
powerLevel,
|
|
25
|
+
resourceID: "server-a",
|
|
26
|
+
resourceType: "server",
|
|
27
|
+
userID,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("server permissions", () => {
|
|
32
|
+
it("treats a minimum power level as inclusive", () => {
|
|
33
|
+
const permissions = [permission("alice", 50)];
|
|
34
|
+
expect(hasPermission(permissions, "server-a", 50)).toBe(true);
|
|
35
|
+
expect(userHasPermission(permissions, "alice", 50)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not let an ordinary member remove another user's permission", () => {
|
|
39
|
+
const actor = permission("alice", 0);
|
|
40
|
+
const target = permission("bob", 0);
|
|
41
|
+
expect(canDeletePermission([actor], "alice", target, 50)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("allows self-removal and higher-power moderation", () => {
|
|
45
|
+
const own = permission("alice", 0);
|
|
46
|
+
const admin = permission("admin", 100);
|
|
47
|
+
expect(canDeletePermission([own], "alice", own, 50)).toBe(true);
|
|
48
|
+
expect(canDeletePermission([admin], "admin", own, 50)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|