@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
|
@@ -86,6 +86,7 @@ export const getPasskeyDeviceRouter = (
|
|
|
86
86
|
// "I lost my phone, sign in with the passkey, wipe the
|
|
87
87
|
// old device, then recover onto a new one."
|
|
88
88
|
await db.deleteDevice(deviceID);
|
|
89
|
+
disconnectDevices?.([deviceID]);
|
|
89
90
|
// Tell whoever's online that the device-list shape
|
|
90
91
|
// changed; clients use this to refresh the Settings →
|
|
91
92
|
// Devices view in real time.
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
|
|
9
|
+
import express from "express";
|
|
10
|
+
|
|
11
|
+
import { PasswordUpdatePayloadSchema } from "@vex-chat/types";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
hashPasswordArgon2,
|
|
15
|
+
validateAccountPassword,
|
|
16
|
+
verifyPassword,
|
|
17
|
+
} from "../Database.ts";
|
|
18
|
+
|
|
19
|
+
import { AppError } from "./errors.ts";
|
|
20
|
+
import { passwordUpdateLimiter } from "./rateLimit.ts";
|
|
21
|
+
import { getParam, getUser } from "./utils.ts";
|
|
22
|
+
|
|
23
|
+
import { protectAnyAuth } from "./index.ts";
|
|
24
|
+
|
|
25
|
+
export const getPasswordRouter = (db: Database) => {
|
|
26
|
+
const router = express.Router();
|
|
27
|
+
|
|
28
|
+
router.patch(
|
|
29
|
+
"/user/:id/password",
|
|
30
|
+
protectAnyAuth,
|
|
31
|
+
passwordUpdateLimiter,
|
|
32
|
+
async (req, res) => {
|
|
33
|
+
const authenticatedUser = getUser(req);
|
|
34
|
+
const userID = getParam(req, "id");
|
|
35
|
+
if (authenticatedUser.userID !== userID) {
|
|
36
|
+
throw new AppError(403, "Not authorized for this account");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const payload = PasswordUpdatePayloadSchema.parse(req.body);
|
|
40
|
+
const user = await db.retrieveUser(userID);
|
|
41
|
+
if (!user) {
|
|
42
|
+
throw new AppError(404, "Account not found");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const passwordError = validateAccountPassword(
|
|
46
|
+
payload.newPassword,
|
|
47
|
+
user.username,
|
|
48
|
+
);
|
|
49
|
+
if (passwordError) {
|
|
50
|
+
throw new AppError(400, passwordError);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (req.passkey) {
|
|
54
|
+
const passkey = await db.retrievePasskeyInternal(
|
|
55
|
+
req.passkey.passkeyID,
|
|
56
|
+
);
|
|
57
|
+
if (!passkey || passkey.userID !== userID) {
|
|
58
|
+
throw new AppError(401, "Passkey authentication required");
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
if (
|
|
62
|
+
!req.device ||
|
|
63
|
+
req.device.owner !== userID ||
|
|
64
|
+
!payload.currentPassword
|
|
65
|
+
) {
|
|
66
|
+
throw new AppError(
|
|
67
|
+
401,
|
|
68
|
+
"Current-password authentication required",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const current = await verifyPassword(
|
|
72
|
+
payload.currentPassword,
|
|
73
|
+
user,
|
|
74
|
+
);
|
|
75
|
+
if (!current.valid) {
|
|
76
|
+
throw new AppError(401, "Current password is incorrect");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const reused = await verifyPassword(payload.newPassword, user);
|
|
81
|
+
if (reused.valid) {
|
|
82
|
+
throw new AppError(
|
|
83
|
+
409,
|
|
84
|
+
"New password must be different from the current password",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const passwordHash = await hashPasswordArgon2(payload.newPassword);
|
|
89
|
+
await db.rehashPassword(userID, passwordHash);
|
|
90
|
+
res.setHeader("Cache-Control", "no-store");
|
|
91
|
+
res.sendStatus(204);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return router;
|
|
96
|
+
};
|
|
@@ -6,6 +6,24 @@
|
|
|
6
6
|
|
|
7
7
|
import type { Permission } from "@vex-chat/types";
|
|
8
8
|
|
|
9
|
+
export function canDeletePermission(
|
|
10
|
+
permissions: Permission[],
|
|
11
|
+
actorUserID: string,
|
|
12
|
+
target: Permission,
|
|
13
|
+
adminPowerLevel: number,
|
|
14
|
+
): boolean {
|
|
15
|
+
if (target.userID === actorUserID) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return permissions.some(
|
|
19
|
+
(permission) =>
|
|
20
|
+
permission.userID === actorUserID &&
|
|
21
|
+
permission.resourceID === target.resourceID &&
|
|
22
|
+
permission.powerLevel >= adminPowerLevel &&
|
|
23
|
+
permission.powerLevel > target.powerLevel,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
/**
|
|
10
28
|
* Check whether any permission in the list covers the given resource
|
|
11
29
|
* (any power level).
|
|
@@ -27,7 +45,7 @@ export function hasPermission(
|
|
|
27
45
|
minPowerLevel: number,
|
|
28
46
|
): boolean {
|
|
29
47
|
return permissions.some(
|
|
30
|
-
(p) => p.resourceID === resourceID && p.powerLevel
|
|
48
|
+
(p) => p.resourceID === resourceID && p.powerLevel >= minPowerLevel,
|
|
31
49
|
);
|
|
32
50
|
}
|
|
33
51
|
|
|
@@ -41,6 +59,6 @@ export function userHasPermission(
|
|
|
41
59
|
minPowerLevel: number,
|
|
42
60
|
): boolean {
|
|
43
61
|
return permissions.some(
|
|
44
|
-
(p) => p.userID === userID && p.powerLevel
|
|
62
|
+
(p) => p.userID === userID && p.powerLevel >= minPowerLevel,
|
|
45
63
|
);
|
|
46
64
|
}
|
package/src/server/rateLimit.ts
CHANGED
|
@@ -74,15 +74,14 @@ function disableRateLimitsByEnv(): boolean {
|
|
|
74
74
|
* versions silently collapsed all IPv4-mapped IPv6 addresses into
|
|
75
75
|
* one bucket, which let attackers bypass the limiter).
|
|
76
76
|
*
|
|
77
|
-
* `trust proxy` must
|
|
78
|
-
* `req.ip`
|
|
77
|
+
* `trust proxy` must match the real deployment topology (see Spire.ts) so
|
|
78
|
+
* `req.ip` cannot be spoofed and does not collapse all traffic onto a proxy.
|
|
79
79
|
*/
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Bucket requests by the real client IP, IPv6-safe.
|
|
83
83
|
*
|
|
84
|
-
* `req.ip`
|
|
85
|
-
* set to `1` in Spire's constructor. We still run it through
|
|
84
|
+
* `req.ip` reflects the explicitly configured proxy hop count. We still run it through
|
|
86
85
|
* `ipKeyGenerator` so IPv4-mapped IPv6 (`::ffff:1.2.3.4`) doesn't
|
|
87
86
|
* collide with unrelated IPv6 addresses in the same /56.
|
|
88
87
|
*/
|
|
@@ -123,6 +122,50 @@ export const authLimiter = rateLimit({
|
|
|
123
122
|
windowMs: 15 * 60 * 1000,
|
|
124
123
|
});
|
|
125
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Cross-IP password defense. This account bucket complements the IP bucket so
|
|
127
|
+
* a distributed password spray cannot make unlimited guesses at one account.
|
|
128
|
+
*/
|
|
129
|
+
export const accountAuthLimiter = rateLimit({
|
|
130
|
+
keyGenerator: (req) => {
|
|
131
|
+
const body: unknown = req.body;
|
|
132
|
+
const username =
|
|
133
|
+
typeof body === "object" &&
|
|
134
|
+
body !== null &&
|
|
135
|
+
"username" in body &&
|
|
136
|
+
typeof body.username === "string"
|
|
137
|
+
? body.username.trim().toLowerCase().slice(0, 64)
|
|
138
|
+
: "";
|
|
139
|
+
return username.length > 0
|
|
140
|
+
? `account:${username}`
|
|
141
|
+
: `invalid:${keyByIp(req)}`;
|
|
142
|
+
},
|
|
143
|
+
legacyHeaders: false,
|
|
144
|
+
limit: 100,
|
|
145
|
+
skip: devApiKeySkipsRateLimits,
|
|
146
|
+
skipSuccessfulRequests: true,
|
|
147
|
+
standardHeaders: "draft-7",
|
|
148
|
+
windowMs: 15 * 60 * 1000,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Limit repeated password replacement attempts per authenticated account.
|
|
153
|
+
* This is intentionally tighter than sign-in because each failed request has
|
|
154
|
+
* already passed bearer/device validation and still consumes an Argon2 check.
|
|
155
|
+
*/
|
|
156
|
+
export const passwordUpdateLimiter = rateLimit({
|
|
157
|
+
keyGenerator: (req) =>
|
|
158
|
+
req.user?.userID
|
|
159
|
+
? `password:${req.user.userID}`
|
|
160
|
+
: `invalid:${keyByIp(req)}`,
|
|
161
|
+
legacyHeaders: false,
|
|
162
|
+
limit: 10,
|
|
163
|
+
skip: devApiKeySkipsRateLimits,
|
|
164
|
+
skipSuccessfulRequests: true,
|
|
165
|
+
standardHeaders: "draft-7",
|
|
166
|
+
windowMs: 15 * 60 * 1000,
|
|
167
|
+
});
|
|
168
|
+
|
|
126
169
|
/**
|
|
127
170
|
* Upload endpoint limiter. Applied per-route to /file and /avatar
|
|
128
171
|
* POSTs, BEFORE multer parses the multipart body. Caps the number of
|
|
@@ -0,0 +1,199 @@
|
|
|
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 { Server } from "@vex-chat/types";
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as fsp from "node:fs/promises";
|
|
12
|
+
|
|
13
|
+
import express from "express";
|
|
14
|
+
|
|
15
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
16
|
+
|
|
17
|
+
import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
|
|
18
|
+
import multer from "multer";
|
|
19
|
+
import { z } from "zod/v4";
|
|
20
|
+
|
|
21
|
+
import { POWER_LEVELS } from "../ClientManager.ts";
|
|
22
|
+
import { msgpack } from "../utils/msgpack.ts";
|
|
23
|
+
|
|
24
|
+
import { hasPermission } from "./permissions.ts";
|
|
25
|
+
import { uploadLimiter } from "./rateLimit.ts";
|
|
26
|
+
import { getParam, getUser } from "./utils.ts";
|
|
27
|
+
|
|
28
|
+
import { ALLOWED_IMAGE_TYPES, protect } from "./index.ts";
|
|
29
|
+
|
|
30
|
+
const SERVER_ICON_DIR = "server-icons";
|
|
31
|
+
const MAX_SERVER_ICON_BYTES = 5 * 1024 * 1024;
|
|
32
|
+
const safePathParam = z.string().regex(/^[a-zA-Z0-9._-]+$/);
|
|
33
|
+
const serverIconUpload = multer({
|
|
34
|
+
limits: { fields: 1, files: 1, fileSize: MAX_SERVER_ICON_BYTES, parts: 2 },
|
|
35
|
+
});
|
|
36
|
+
const serverIconJsonPayload = z.object({
|
|
37
|
+
file: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(1)
|
|
40
|
+
.max(Math.ceil((MAX_SERVER_ICON_BYTES * 4) / 3) + 4),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
type NotifyServerChange = (serverID: string) => Promise<void>;
|
|
44
|
+
|
|
45
|
+
export async function deleteServerIconFile(iconID: string): Promise<void> {
|
|
46
|
+
const safeID = safePathParam.safeParse(iconID);
|
|
47
|
+
if (!safeID.success) return;
|
|
48
|
+
await fsp
|
|
49
|
+
.unlink(`${SERVER_ICON_DIR}/${safeID.data}`)
|
|
50
|
+
.catch(() => undefined);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const getServerIconRouter = (
|
|
54
|
+
db: Database,
|
|
55
|
+
notifyServerChange: NotifyServerChange,
|
|
56
|
+
) => {
|
|
57
|
+
const router = express.Router();
|
|
58
|
+
|
|
59
|
+
router.get("/:iconID", async (req, res) => {
|
|
60
|
+
const safeID = safePathParam.safeParse(getParam(req, "iconID"));
|
|
61
|
+
if (!safeID.success) {
|
|
62
|
+
res.sendStatus(400);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const filePath = `${SERVER_ICON_DIR}/${safeID.data}`;
|
|
67
|
+
const typeDetails = await fileTypeFromFile(filePath).catch(() => null);
|
|
68
|
+
if (!typeDetails) {
|
|
69
|
+
res.sendStatus(404);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
res.set("Content-Type", typeDetails.mime);
|
|
74
|
+
res.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
75
|
+
res.set("Cross-Origin-Resource-Policy", "cross-origin");
|
|
76
|
+
const stream = fs.createReadStream(filePath);
|
|
77
|
+
stream.on("error", () => {
|
|
78
|
+
if (!res.headersSent) res.sendStatus(500);
|
|
79
|
+
else res.destroy();
|
|
80
|
+
});
|
|
81
|
+
stream.pipe(res);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
router.post("/:serverID/json", uploadLimiter, protect, async (req, res) => {
|
|
85
|
+
const parsed = serverIconJsonPayload.safeParse(req.body);
|
|
86
|
+
if (!parsed.success) {
|
|
87
|
+
res.status(400).json({
|
|
88
|
+
error: "Invalid server icon payload",
|
|
89
|
+
issues: parsed.error.issues,
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let buffer: Buffer;
|
|
95
|
+
try {
|
|
96
|
+
buffer = Buffer.from(XUtils.decodeBase64(parsed.data.file));
|
|
97
|
+
} catch {
|
|
98
|
+
res.status(400).json({ error: "Icon must be valid base64." });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await saveIcon(req, res, db, notifyServerChange, buffer);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
router.post(
|
|
105
|
+
"/:serverID",
|
|
106
|
+
uploadLimiter,
|
|
107
|
+
protect,
|
|
108
|
+
serverIconUpload.single("icon"),
|
|
109
|
+
async (req, res) => {
|
|
110
|
+
if (!req.file) {
|
|
111
|
+
res.sendStatus(400);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await saveIcon(req, res, db, notifyServerChange, req.file.buffer);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
router.delete("/:serverID", protect, async (req, res) => {
|
|
119
|
+
const serverID = getParam(req, "serverID");
|
|
120
|
+
if (!(await canManageServer(db, getUser(req).userID, serverID))) {
|
|
121
|
+
res.sendStatus(403);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const existing = await db.retrieveServer(serverID);
|
|
126
|
+
if (!existing) {
|
|
127
|
+
res.sendStatus(404);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const updated = await db.updateServer(serverID, { icon: null });
|
|
131
|
+
if (!updated) {
|
|
132
|
+
res.sendStatus(404);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (existing.icon) await deleteServerIconFile(existing.icon);
|
|
137
|
+
await notifyServerChange(serverID);
|
|
138
|
+
res.send(msgpack.encode(updated));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return router;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
async function canManageServer(
|
|
145
|
+
db: Database,
|
|
146
|
+
userID: string,
|
|
147
|
+
serverID: string,
|
|
148
|
+
): Promise<boolean> {
|
|
149
|
+
const permissions = await db.retrievePermissions(userID, "server");
|
|
150
|
+
return hasPermission(permissions, serverID, POWER_LEVELS.CREATE);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function saveIcon(
|
|
154
|
+
req: express.Request,
|
|
155
|
+
res: express.Response,
|
|
156
|
+
db: Database,
|
|
157
|
+
notifyServerChange: NotifyServerChange,
|
|
158
|
+
buffer: Buffer,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const serverID = getParam(req, "serverID");
|
|
161
|
+
if (!(await canManageServer(db, getUser(req).userID, serverID))) {
|
|
162
|
+
res.sendStatus(403);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (buffer.byteLength > MAX_SERVER_ICON_BYTES) {
|
|
166
|
+
res.sendStatus(413);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const typeDetails = await fileTypeFromBuffer(buffer);
|
|
171
|
+
if (!ALLOWED_IMAGE_TYPES.includes(typeDetails?.mime ?? "")) {
|
|
172
|
+
res.status(400).json({
|
|
173
|
+
error: "Unsupported icon type. Use JPEG, PNG, GIF, APNG, AVIF, or WebP.",
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const existing = await db.retrieveServer(serverID);
|
|
179
|
+
if (!existing) {
|
|
180
|
+
res.sendStatus(404);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const iconID = crypto.randomUUID();
|
|
185
|
+
const filePath = `${SERVER_ICON_DIR}/${iconID}`;
|
|
186
|
+
try {
|
|
187
|
+
await fsp.writeFile(filePath, buffer, { flag: "wx" });
|
|
188
|
+
const updated = await db.updateServer(serverID, { icon: iconID });
|
|
189
|
+
if (!updated) {
|
|
190
|
+
throw new Error("Server disappeared while its icon was updating.");
|
|
191
|
+
}
|
|
192
|
+
if (existing.icon) await deleteServerIconFile(existing.icon);
|
|
193
|
+
await notifyServerChange(serverID);
|
|
194
|
+
res.send(msgpack.encode(updated satisfies Server));
|
|
195
|
+
} catch (error: unknown) {
|
|
196
|
+
await fsp.unlink(filePath).catch(() => undefined);
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/server/user.ts
CHANGED
|
@@ -24,7 +24,9 @@ import {
|
|
|
24
24
|
import { stringify } from "uuid";
|
|
25
25
|
import { z } from "zod/v4";
|
|
26
26
|
|
|
27
|
+
import { MAX_ACTIVE_DEVICES_PER_USER } from "../Database.ts";
|
|
27
28
|
import { msgpack } from "../utils/msgpack.ts";
|
|
29
|
+
import { verifyDevicePayloadPreKeySignature } from "../utils/preKeySignature.ts";
|
|
28
30
|
import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
|
|
29
31
|
|
|
30
32
|
import { AppError } from "./errors.ts";
|
|
@@ -491,6 +493,7 @@ export const getUserRouter = (
|
|
|
491
493
|
data?: unknown,
|
|
492
494
|
deviceID?: string,
|
|
493
495
|
) => void,
|
|
496
|
+
disconnectDevices?: (deviceIDs: string[]) => void,
|
|
494
497
|
) => {
|
|
495
498
|
const router = express.Router();
|
|
496
499
|
|
|
@@ -581,7 +584,7 @@ export const getUserRouter = (
|
|
|
581
584
|
authenticatorSelection: {
|
|
582
585
|
requireResidentKey: false,
|
|
583
586
|
residentKey: "preferred",
|
|
584
|
-
userVerification: "
|
|
587
|
+
userVerification: "required",
|
|
585
588
|
},
|
|
586
589
|
excludeCredentials: [],
|
|
587
590
|
rpID,
|
|
@@ -675,16 +678,20 @@ export const getUserRouter = (
|
|
|
675
678
|
expectedChallenge: passkeyRegistration.challenge,
|
|
676
679
|
expectedOrigin,
|
|
677
680
|
expectedRPID: rpID,
|
|
678
|
-
requireUserVerification:
|
|
681
|
+
requireUserVerification: true,
|
|
679
682
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
|
|
680
683
|
response: parsed.data
|
|
681
684
|
.response as unknown as RegistrationResponseJSON,
|
|
682
685
|
});
|
|
683
686
|
} catch (err: unknown) {
|
|
687
|
+
const requestId = crypto.randomUUID();
|
|
684
688
|
const message =
|
|
685
689
|
err instanceof Error ? err.message : String(err);
|
|
690
|
+
console.warn(
|
|
691
|
+
`[spire] pending-device WebAuthn registration failed requestId=${requestId} message=${message}`,
|
|
692
|
+
);
|
|
686
693
|
res.status(400).send({
|
|
687
|
-
error: "Passkey attestation
|
|
694
|
+
error: "Passkey attestation could not be verified.",
|
|
688
695
|
});
|
|
689
696
|
return;
|
|
690
697
|
}
|
|
@@ -811,6 +818,10 @@ export const getUserRouter = (
|
|
|
811
818
|
|
|
812
819
|
router.get("/:id/permissions", protect, async (req, res) => {
|
|
813
820
|
const userDetails = getUser(req);
|
|
821
|
+
if (getParam(req, "id") !== userDetails.userID) {
|
|
822
|
+
res.sendStatus(403);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
814
825
|
const permissions = await db.retrievePermissions(
|
|
815
826
|
userDetails.userID,
|
|
816
827
|
"all",
|
|
@@ -820,12 +831,20 @@ export const getUserRouter = (
|
|
|
820
831
|
|
|
821
832
|
router.get("/:id/servers", protect, async (req, res) => {
|
|
822
833
|
const userDetails = getUser(req);
|
|
834
|
+
if (getParam(req, "id") !== userDetails.userID) {
|
|
835
|
+
res.sendStatus(403);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
823
838
|
const servers = await db.retrieveServers(userDetails.userID);
|
|
824
839
|
res.send(msgpack.encode(servers));
|
|
825
840
|
});
|
|
826
841
|
|
|
827
842
|
router.get("/:id/servers/bootstrap", protect, async (req, res) => {
|
|
828
843
|
const userDetails = getUser(req);
|
|
844
|
+
if (getParam(req, "id") !== userDetails.userID) {
|
|
845
|
+
res.sendStatus(403);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
829
848
|
const payload = await db.retrieveServerChannelBootstrap(
|
|
830
849
|
userDetails.userID,
|
|
831
850
|
);
|
|
@@ -840,8 +859,14 @@ export const getUserRouter = (
|
|
|
840
859
|
return;
|
|
841
860
|
}
|
|
842
861
|
const userDetails = getUser(req);
|
|
843
|
-
|
|
844
|
-
|
|
862
|
+
const currentDevice = req.device;
|
|
863
|
+
if (
|
|
864
|
+
getParam(req, "userID") !== userDetails.userID ||
|
|
865
|
+
userDetails.userID !== device.owner ||
|
|
866
|
+
!currentDevice ||
|
|
867
|
+
currentDevice.owner !== userDetails.userID
|
|
868
|
+
) {
|
|
869
|
+
res.sendStatus(403);
|
|
845
870
|
return;
|
|
846
871
|
}
|
|
847
872
|
const deviceList = await db.retrieveUserDeviceList([
|
|
@@ -855,6 +880,7 @@ export const getUserRouter = (
|
|
|
855
880
|
}
|
|
856
881
|
|
|
857
882
|
await db.deleteDevice(device.deviceID);
|
|
883
|
+
disconnectDevices?.([device.deviceID]);
|
|
858
884
|
res.sendStatus(200);
|
|
859
885
|
});
|
|
860
886
|
|
|
@@ -893,9 +919,21 @@ export const getUserRouter = (
|
|
|
893
919
|
}
|
|
894
920
|
|
|
895
921
|
if (tokenValidator(stringify(token), TokenScopes.Device)) {
|
|
922
|
+
if (!(await verifyDevicePayloadPreKeySignature(deviceData))) {
|
|
923
|
+
res.status(400).send({
|
|
924
|
+
error: "Signed prekey signature is invalid.",
|
|
925
|
+
});
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
896
928
|
const userDevices = await db.retrieveUserDeviceList([
|
|
897
929
|
userDetails.userID,
|
|
898
930
|
]);
|
|
931
|
+
if (userDevices.length >= MAX_ACTIVE_DEVICES_PER_USER) {
|
|
932
|
+
res.status(409).send({
|
|
933
|
+
error: `Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices. Remove an old device before adding another.`,
|
|
934
|
+
});
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
899
937
|
if (userDevices.length === 0) {
|
|
900
938
|
try {
|
|
901
939
|
const device = await db.createDevice(
|
|
@@ -1026,10 +1064,7 @@ export const getUserRouter = (
|
|
|
1026
1064
|
return;
|
|
1027
1065
|
}
|
|
1028
1066
|
|
|
1029
|
-
|
|
1030
|
-
// Older clients may still satisfy this with an approval-side passkey.
|
|
1031
|
-
const approvedByPasskeyID =
|
|
1032
|
-
pending.requesterPasskeyID ?? req.passkey?.passkeyID;
|
|
1067
|
+
const approvedByPasskeyID = pending.requesterPasskeyID;
|
|
1033
1068
|
if (approvedByPasskeyID) {
|
|
1034
1069
|
const passkey =
|
|
1035
1070
|
await db.retrievePasskeyInternal(approvedByPasskeyID);
|
package/src/types/express.ts
CHANGED
|
@@ -22,10 +22,9 @@ declare global {
|
|
|
22
22
|
device?: Device;
|
|
23
23
|
exp?: number;
|
|
24
24
|
/**
|
|
25
|
-
* Set
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* may list/delete devices and recover/reject enrollments, but
|
|
25
|
+
* Set for a revalidated passkey-scoped JWT. The presence of
|
|
26
|
+
* `req.passkey` (without `req.device`) marks an admin-only request
|
|
27
|
+
* that may list/delete devices and recover/reject enrollments, but
|
|
29
28
|
* cannot send mail or do anything device-specific.
|
|
30
29
|
*/
|
|
31
30
|
passkey?: { passkeyID: string };
|
|
@@ -0,0 +1,34 @@
|
|
|
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 { JwtPayload, SignOptions } from "jsonwebtoken";
|
|
8
|
+
|
|
9
|
+
import jwt from "jsonwebtoken";
|
|
10
|
+
|
|
11
|
+
import { getJwtSecret } from "./jwtSecret.ts";
|
|
12
|
+
|
|
13
|
+
const JWT_AUDIENCE = "vex-client";
|
|
14
|
+
const JWT_ISSUER = "vex-spire";
|
|
15
|
+
|
|
16
|
+
export function signAuthJwt(
|
|
17
|
+
payload: Record<string, unknown>,
|
|
18
|
+
expiresIn: NonNullable<SignOptions["expiresIn"]>,
|
|
19
|
+
): string {
|
|
20
|
+
return jwt.sign(payload, getJwtSecret(), {
|
|
21
|
+
algorithm: "HS256",
|
|
22
|
+
audience: JWT_AUDIENCE,
|
|
23
|
+
expiresIn,
|
|
24
|
+
issuer: JWT_ISSUER,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function verifyAuthJwt(token: string): JwtPayload | string {
|
|
29
|
+
return jwt.verify(token, getJwtSecret(), {
|
|
30
|
+
algorithms: ["HS256"],
|
|
31
|
+
audience: JWT_AUDIENCE,
|
|
32
|
+
issuer: JWT_ISSUER,
|
|
33
|
+
});
|
|
34
|
+
}
|
package/src/utils/loadEnv.ts
CHANGED
|
@@ -10,6 +10,7 @@ const REQUIRED_ENV_VARS = ["DB_TYPE", "JWT_SECRET", "SPK"] as const;
|
|
|
10
10
|
const NORMALIZED_ENV_VARS = [
|
|
11
11
|
...REQUIRED_ENV_VARS,
|
|
12
12
|
"API_PORT",
|
|
13
|
+
"SPIRE_TRUST_PROXY_HOPS",
|
|
13
14
|
"SPIRE_FIPS",
|
|
14
15
|
"SPIRE_PASSKEY_RP_ID",
|
|
15
16
|
"SPIRE_PASSKEY_RP_NAME",
|
|
@@ -82,6 +83,9 @@ export function validateSpireRuntimeEnv(
|
|
|
82
83
|
"JWT_SECRET must be set. Generate one with `pnpm --filter @vex-chat/spire gen-spk` or `gen-spk-fips`.",
|
|
83
84
|
);
|
|
84
85
|
}
|
|
86
|
+
if (jwtSecret.length < 32) {
|
|
87
|
+
throw new Error("JWT_SECRET must contain at least 32 characters.");
|
|
88
|
+
}
|
|
85
89
|
if (jwtSecret === spk) {
|
|
86
90
|
throw new Error("JWT_SECRET must be separate from SPK.");
|
|
87
91
|
}
|
|
@@ -6,13 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { DevicePayload, PreKeysWS } from "@vex-chat/types";
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
getCryptoProfile,
|
|
11
|
-
xConcat,
|
|
12
|
-
xConstants,
|
|
13
|
-
xEncode,
|
|
14
|
-
XUtils,
|
|
15
|
-
} from "@vex-chat/crypto";
|
|
9
|
+
import { xPreKeySignaturePayload, XUtils } from "@vex-chat/crypto";
|
|
16
10
|
|
|
17
11
|
import { spireXSignOpenAsync } from "./spireXSignOpenAsync.ts";
|
|
18
12
|
|
|
@@ -24,6 +18,7 @@ export async function verifyDevicePayloadPreKeySignature(
|
|
|
24
18
|
XUtils.decodeHex(payload.preKey),
|
|
25
19
|
XUtils.decodeHex(payload.preKeySignature),
|
|
26
20
|
payload.signKey,
|
|
21
|
+
"signed",
|
|
27
22
|
);
|
|
28
23
|
} catch {
|
|
29
24
|
return false;
|
|
@@ -33,20 +28,21 @@ export async function verifyDevicePayloadPreKeySignature(
|
|
|
33
28
|
export async function verifyPreKeyWsSignature(
|
|
34
29
|
preKey: PreKeysWS,
|
|
35
30
|
signKeyHex: string,
|
|
31
|
+
kind: "one-time" | "signed",
|
|
36
32
|
): Promise<boolean> {
|
|
37
|
-
return verifySignedPreKey(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
: xEncode(xConstants.CURVE, publicKey);
|
|
33
|
+
return verifySignedPreKey(
|
|
34
|
+
preKey.publicKey,
|
|
35
|
+
preKey.signature,
|
|
36
|
+
signKeyHex,
|
|
37
|
+
kind,
|
|
38
|
+
);
|
|
44
39
|
}
|
|
45
40
|
|
|
46
41
|
async function verifySignedPreKey(
|
|
47
42
|
publicKey: Uint8Array,
|
|
48
43
|
signature: Uint8Array,
|
|
49
44
|
signKeyHex: string,
|
|
45
|
+
kind: "one-time" | "signed",
|
|
50
46
|
): Promise<boolean> {
|
|
51
47
|
try {
|
|
52
48
|
const opened = await spireXSignOpenAsync(
|
|
@@ -54,7 +50,8 @@ async function verifySignedPreKey(
|
|
|
54
50
|
XUtils.decodeHex(signKeyHex),
|
|
55
51
|
);
|
|
56
52
|
return Boolean(
|
|
57
|
-
opened &&
|
|
53
|
+
opened &&
|
|
54
|
+
XUtils.bytesEqual(opened, xPreKeySignaturePayload(publicKey, kind)),
|
|
58
55
|
);
|
|
59
56
|
} catch {
|
|
60
57
|
return false;
|