@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.
Files changed (90) hide show
  1. package/README.md +9 -8
  2. package/dist/Database.d.ts +20 -11
  3. package/dist/Database.js +229 -118
  4. package/dist/Database.js.map +1 -1
  5. package/dist/Spire.d.ts +4 -2
  6. package/dist/Spire.js +155 -72
  7. package/dist/Spire.js.map +1 -1
  8. package/dist/db/schema.d.ts +0 -2
  9. package/dist/migrations/2026-04-06_initial-schema.js +4 -5
  10. package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
  11. package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
  12. package/dist/migrations/2026-07-14_query-indexes.js +31 -0
  13. package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
  14. package/dist/server/avatar.js +33 -6
  15. package/dist/server/avatar.js.map +1 -1
  16. package/dist/server/cliPasskeyPage.js +109 -11
  17. package/dist/server/cliPasskeyPage.js.map +1 -1
  18. package/dist/server/errors.js +8 -0
  19. package/dist/server/errors.js.map +1 -1
  20. package/dist/server/file.js +35 -12
  21. package/dist/server/file.js.map +1 -1
  22. package/dist/server/index.d.ts +8 -0
  23. package/dist/server/index.js +319 -56
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/passkey.js +367 -29
  26. package/dist/server/passkey.js.map +1 -1
  27. package/dist/server/passkeyDevices.js +1 -0
  28. package/dist/server/passkeyDevices.js.map +1 -1
  29. package/dist/server/password.d.ts +7 -0
  30. package/dist/server/password.js +58 -0
  31. package/dist/server/password.js.map +1 -0
  32. package/dist/server/permissions.d.ts +1 -0
  33. package/dist/server/permissions.js +11 -2
  34. package/dist/server/permissions.js.map +1 -1
  35. package/dist/server/rateLimit.d.ts +11 -0
  36. package/dist/server/rateLimit.js +43 -4
  37. package/dist/server/rateLimit.js.map +1 -1
  38. package/dist/server/serverIcon.d.ts +10 -0
  39. package/dist/server/serverIcon.js +158 -0
  40. package/dist/server/serverIcon.js.map +1 -0
  41. package/dist/server/user.d.ts +1 -1
  42. package/dist/server/user.js +40 -9
  43. package/dist/server/user.js.map +1 -1
  44. package/dist/types/express.d.ts +3 -4
  45. package/dist/types/express.js.map +1 -1
  46. package/dist/utils/authJwt.d.ts +8 -0
  47. package/dist/utils/authJwt.js +25 -0
  48. package/dist/utils/authJwt.js.map +1 -0
  49. package/dist/utils/loadEnv.js +4 -0
  50. package/dist/utils/loadEnv.js.map +1 -1
  51. package/dist/utils/preKeySignature.d.ts +1 -1
  52. package/dist/utils/preKeySignature.js +7 -11
  53. package/dist/utils/preKeySignature.js.map +1 -1
  54. package/package.json +7 -7
  55. package/src/Database.ts +294 -152
  56. package/src/Spire.ts +412 -285
  57. package/src/__tests__/Database.spec.ts +126 -3
  58. package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
  59. package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
  60. package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
  61. package/src/__tests__/connectAuth.spec.ts +146 -4
  62. package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
  63. package/src/__tests__/passkeyDevices.spec.ts +94 -0
  64. package/src/__tests__/passkeys.spec.ts +7 -2
  65. package/src/__tests__/password.spec.ts +223 -0
  66. package/src/__tests__/permissions.spec.ts +50 -0
  67. package/src/__tests__/preKeySignature.spec.ts +20 -3
  68. package/src/__tests__/serverManagement.spec.ts +262 -0
  69. package/src/db/schema.ts +0 -2
  70. package/src/migrations/2026-04-06_initial-schema.ts +4 -5
  71. package/src/migrations/2026-07-14_query-indexes.ts +34 -0
  72. package/src/server/avatar.ts +32 -6
  73. package/src/server/cliPasskeyPage.ts +109 -11
  74. package/src/server/errors.ts +7 -0
  75. package/src/server/file.ts +34 -13
  76. package/src/server/index.ts +494 -139
  77. package/src/server/passkey.ts +531 -31
  78. package/src/server/passkeyDevices.ts +1 -0
  79. package/src/server/password.ts +96 -0
  80. package/src/server/permissions.ts +20 -2
  81. package/src/server/rateLimit.ts +47 -4
  82. package/src/server/serverIcon.ts +199 -0
  83. package/src/server/user.ts +44 -9
  84. package/src/types/express.ts +3 -4
  85. package/src/utils/authJwt.ts +34 -0
  86. package/src/utils/loadEnv.ts +4 -0
  87. package/src/utils/preKeySignature.ts +12 -15
  88. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
  89. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
  90. 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 > minPowerLevel,
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 > minPowerLevel,
62
+ (p) => p.userID === userID && p.powerLevel >= minPowerLevel,
45
63
  );
46
64
  }
@@ -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 be set on the Express app (see Spire.ts) so
78
- * `req.ip` returns the real client address, not the immediate proxy.
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` is already populated correctly because `trust proxy` is
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
+ }
@@ -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: "preferred",
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: false,
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 invalid: " + message,
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
- if (userDetails.userID !== device.owner) {
844
- res.sendStatus(401);
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
- // New clients put the passkey proof on the requesting device.
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);
@@ -22,10 +22,9 @@ declare global {
22
22
  device?: Device;
23
23
  exp?: number;
24
24
  /**
25
- * Set by `checkPasskey` middleware when the bearer token is
26
- * a passkey-scoped JWT. The presence of `req.passkey`
27
- * (without `req.device`) marks an admin-only request that
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
+ }
@@ -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(preKey.publicKey, preKey.signature, signKeyHex);
38
- }
39
-
40
- function preKeySignPayload(publicKey: Uint8Array): Uint8Array {
41
- return getCryptoProfile() === "fips"
42
- ? xConcat(new Uint8Array([0xa1]), publicKey)
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 && XUtils.bytesEqual(opened, preKeySignPayload(publicKey)),
53
+ opened &&
54
+ XUtils.bytesEqual(opened, xPreKeySignaturePayload(publicKey, kind)),
58
55
  );
59
56
  } catch {
60
57
  return false;