@vex-chat/spire 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +9 -8
  2. package/dist/Database.d.ts +12 -11
  3. package/dist/Database.js +161 -118
  4. package/dist/Database.js.map +1 -1
  5. package/dist/Spire.d.ts +4 -2
  6. package/dist/Spire.js +154 -71
  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 +30 -4
  15. package/dist/server/avatar.js.map +1 -1
  16. package/dist/server/errors.js +8 -0
  17. package/dist/server/errors.js.map +1 -1
  18. package/dist/server/file.js +35 -12
  19. package/dist/server/file.js.map +1 -1
  20. package/dist/server/index.d.ts +8 -0
  21. package/dist/server/index.js +157 -41
  22. package/dist/server/index.js.map +1 -1
  23. package/dist/server/passkey.js +41 -26
  24. package/dist/server/passkey.js.map +1 -1
  25. package/dist/server/passkeyDevices.js +1 -0
  26. package/dist/server/passkeyDevices.js.map +1 -1
  27. package/dist/server/password.d.ts +7 -0
  28. package/dist/server/password.js +58 -0
  29. package/dist/server/password.js.map +1 -0
  30. package/dist/server/permissions.d.ts +1 -0
  31. package/dist/server/permissions.js +11 -2
  32. package/dist/server/permissions.js.map +1 -1
  33. package/dist/server/rateLimit.d.ts +11 -0
  34. package/dist/server/rateLimit.js +43 -4
  35. package/dist/server/rateLimit.js.map +1 -1
  36. package/dist/server/user.d.ts +1 -1
  37. package/dist/server/user.js +40 -9
  38. package/dist/server/user.js.map +1 -1
  39. package/dist/types/express.d.ts +3 -4
  40. package/dist/types/express.js.map +1 -1
  41. package/dist/utils/authJwt.d.ts +8 -0
  42. package/dist/utils/authJwt.js +25 -0
  43. package/dist/utils/authJwt.js.map +1 -0
  44. package/dist/utils/loadEnv.js +4 -0
  45. package/dist/utils/loadEnv.js.map +1 -1
  46. package/dist/utils/preKeySignature.d.ts +1 -1
  47. package/dist/utils/preKeySignature.js +7 -11
  48. package/dist/utils/preKeySignature.js.map +1 -1
  49. package/package.json +7 -7
  50. package/src/Database.ts +211 -152
  51. package/src/Spire.ts +411 -284
  52. package/src/__tests__/Database.spec.ts +126 -3
  53. package/src/__tests__/connectAuth.spec.ts +146 -4
  54. package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
  55. package/src/__tests__/passkeyDevices.spec.ts +94 -0
  56. package/src/__tests__/passkeys.spec.ts +7 -2
  57. package/src/__tests__/password.spec.ts +223 -0
  58. package/src/__tests__/permissions.spec.ts +50 -0
  59. package/src/__tests__/preKeySignature.spec.ts +20 -3
  60. package/src/db/schema.ts +0 -2
  61. package/src/migrations/2026-04-06_initial-schema.ts +4 -5
  62. package/src/migrations/2026-07-14_query-indexes.ts +34 -0
  63. package/src/server/avatar.ts +29 -4
  64. package/src/server/errors.ts +7 -0
  65. package/src/server/file.ts +34 -13
  66. package/src/server/index.ts +286 -111
  67. package/src/server/passkey.ts +51 -28
  68. package/src/server/passkeyDevices.ts +1 -0
  69. package/src/server/password.ts +96 -0
  70. package/src/server/permissions.ts +20 -2
  71. package/src/server/rateLimit.ts +47 -4
  72. package/src/server/user.ts +44 -9
  73. package/src/types/express.ts +3 -4
  74. package/src/utils/authJwt.ts +34 -0
  75. package/src/utils/loadEnv.ts +4 -0
  76. package/src/utils/preKeySignature.ts +12 -15
  77. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
  78. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
  79. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
@@ -28,10 +28,9 @@ import {
28
28
  verifyAuthenticationResponse,
29
29
  verifyRegistrationResponse,
30
30
  } from "@simplewebauthn/server";
31
- import jwt from "jsonwebtoken";
32
31
 
33
32
  import { JWT_EXPIRY_PASSKEY } from "../Spire.ts";
34
- import { getJwtSecret } from "../utils/jwtSecret.ts";
33
+ import { signAuthJwt } from "../utils/authJwt.ts";
35
34
 
36
35
  import { AppError } from "./errors.ts";
37
36
  import { authLimiter } from "./rateLimit.ts";
@@ -39,7 +38,7 @@ import { censorUser, getParam, getUser } from "./utils.ts";
39
38
  import { buildAndroidApkKeyHashOrigins } from "./wellKnown.ts";
40
39
  import { sendWireResponse } from "./wireResponse.ts";
41
40
 
42
- import { protect } from "./index.ts";
41
+ import { protect, protectAnyAuth } from "./index.ts";
43
42
 
44
43
  const REGISTRATION_TTL_MS = 5 * 60 * 1000; // 5 min
45
44
  const AUTHENTICATION_TTL_MS = 5 * 60 * 1000;
@@ -56,6 +55,7 @@ interface PendingAuthentication {
56
55
  interface PendingRegistration {
57
56
  challenge: string;
58
57
  createdAt: number;
58
+ deviceID: string;
59
59
  name: string;
60
60
  userID: string;
61
61
  }
@@ -157,7 +157,7 @@ function sanitizeTransports(input: string[]): AuthenticatorTransportFuture[] {
157
157
  * Issues a passkey-scoped JWT.
158
158
  *
159
159
  * Carries `scope: "passkey"` and the owning userID; deliberately
160
- * shorter-lived than a device JWT (5 min vs 7 days) because a
160
+ * shorter-lived than an account or device JWT (5 min vs 1 hour) because a
161
161
  * passkey JWT grants destructive admin powers (delete a device,
162
162
  * approve an enrollment) without further user verification. Callers
163
163
  * re-do the WebAuthn ceremony when this expires.
@@ -166,14 +166,13 @@ function signPasskeyToken(args: {
166
166
  passkeyID: string;
167
167
  user: ReturnType<typeof censorUser>;
168
168
  }): string {
169
- return jwt.sign(
169
+ return signAuthJwt(
170
170
  {
171
171
  passkey: { passkeyID: args.passkeyID },
172
172
  scope: "passkey" as const,
173
173
  user: args.user,
174
174
  },
175
- getJwtSecret(),
176
- { expiresIn: JWT_EXPIRY_PASSKEY },
175
+ JWT_EXPIRY_PASSKEY,
177
176
  );
178
177
  }
179
178
 
@@ -203,16 +202,13 @@ export const getPasskeyRouter = (db: Database) => {
203
202
  return;
204
203
  }
205
204
 
206
- const existing = await db.retrievePasskeysByUser(userID);
207
- // A freshly registered account may add its first passkey with
208
- // the account bearer before finishing device connect. Every later
209
- // passkey addition must come from an authenticated device session.
210
- if (!req.device && existing.length > 0) {
205
+ if (!req.device || req.device.owner !== userID) {
211
206
  res.status(401).send({
212
- error: "Adding another passkey requires an authenticated device.",
207
+ error: "Adding a passkey requires an authenticated device.",
213
208
  });
214
209
  return;
215
210
  }
211
+ const existing = await db.retrievePasskeysByUser(userID);
216
212
  if (existing.length >= MAX_PASSKEYS_PER_USER) {
217
213
  res.status(409).send({
218
214
  error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
@@ -232,7 +228,7 @@ export const getPasskeyRouter = (db: Database) => {
232
228
  authenticatorSelection: {
233
229
  requireResidentKey: false,
234
230
  residentKey: "preferred",
235
- userVerification: "preferred",
231
+ userVerification: "required",
236
232
  },
237
233
  excludeCredentials: [],
238
234
  rpID,
@@ -247,6 +243,7 @@ export const getPasskeyRouter = (db: Database) => {
247
243
  pendingRegistrations.set(requestID, {
248
244
  challenge: options.challenge,
249
245
  createdAt: Date.now(),
246
+ deviceID: req.device.deviceID,
250
247
  name: parsed.data.name,
251
248
  userID,
252
249
  });
@@ -285,17 +282,20 @@ export const getPasskeyRouter = (db: Database) => {
285
282
  return;
286
283
  }
287
284
 
288
- const existing = await db.retrievePasskeysByUser(userID);
289
- if (!req.device && existing.length > 0) {
285
+ if (!req.device || req.device.owner !== userID) {
290
286
  res.status(401).send({
291
- error: "Adding another passkey requires an authenticated device.",
287
+ error: "Adding a passkey requires an authenticated device.",
292
288
  });
293
289
  return;
294
290
  }
295
291
 
296
292
  pruneRegistrations();
297
293
  const pending = pendingRegistrations.get(parsed.data.requestID);
298
- if (!pending || pending.userID !== userID) {
294
+ if (
295
+ !pending ||
296
+ pending.userID !== userID ||
297
+ pending.deviceID !== req.device.deviceID
298
+ ) {
299
299
  res.status(404).send({
300
300
  error: "Registration request not found or expired.",
301
301
  });
@@ -319,15 +319,14 @@ export const getPasskeyRouter = (db: Database) => {
319
319
  expectedChallenge: pending.challenge,
320
320
  expectedOrigin,
321
321
  expectedRPID: rpID,
322
- requireUserVerification: false,
322
+ requireUserVerification: true,
323
323
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
324
324
  response: rawResponse as RegistrationResponseJSON,
325
325
  });
326
326
  } catch (err: unknown) {
327
- const message =
328
- err instanceof Error ? err.message : String(err);
327
+ logWebAuthnFailure("registration", err);
329
328
  res.status(400).send({
330
- error: "Passkey attestation invalid: " + message,
329
+ error: "Passkey attestation could not be verified.",
331
330
  });
332
331
  return;
333
332
  }
@@ -380,7 +379,7 @@ export const getPasskeyRouter = (db: Database) => {
380
379
  },
381
380
  );
382
381
 
383
- router.get("/user/:id/passkeys", protect, async (req, res) => {
382
+ router.get("/user/:id/passkeys", protectAnyAuth, async (req, res) => {
384
383
  const userDetails = getUser(req);
385
384
  const userID = getParam(req, "id");
386
385
  if (userDetails.userID !== userID) {
@@ -402,6 +401,12 @@ export const getPasskeyRouter = (db: Database) => {
402
401
  res.sendStatus(401);
403
402
  return;
404
403
  }
404
+ if (!req.device || req.device.owner !== userID) {
405
+ res.status(401).send({
406
+ error: "Removing a passkey requires an authenticated device.",
407
+ });
408
+ return;
409
+ }
405
410
  const row = await db.retrievePasskeyInternal(passkeyID);
406
411
  if (!row || row.userID !== userID) {
407
412
  res.sendStatus(404);
@@ -456,7 +461,7 @@ export const getPasskeyRouter = (db: Database) => {
456
461
  const options = await generateAuthenticationOptions({
457
462
  allowCredentials: allowCredentials.filter((c) => c.id.length > 0),
458
463
  rpID,
459
- userVerification: "preferred",
464
+ userVerification: "required",
460
465
  });
461
466
 
462
467
  pruneAuthentications();
@@ -541,13 +546,13 @@ export const getPasskeyRouter = (db: Database) => {
541
546
  expectedChallenge: pending.challenge,
542
547
  expectedOrigin,
543
548
  expectedRPID: rpID,
544
- requireUserVerification: false,
549
+ requireUserVerification: true,
545
550
  response: assertion,
546
551
  });
547
552
  } catch (err: unknown) {
548
- const message = err instanceof Error ? err.message : String(err);
553
+ logWebAuthnFailure("authentication", err);
549
554
  res.status(401).send({
550
- error: "Passkey assertion invalid: " + message,
555
+ error: "Passkey assertion could not be verified.",
551
556
  });
552
557
  return;
553
558
  }
@@ -572,7 +577,17 @@ export const getPasskeyRouter = (db: Database) => {
572
577
  return;
573
578
  }
574
579
 
575
- await db.markPasskeyUsed(passkeyRow.passkeyID, newCounter);
580
+ const counterUpdated = await db.markPasskeyUsed(
581
+ passkeyRow.passkeyID,
582
+ passkeyRow.signCount,
583
+ newCounter,
584
+ );
585
+ if (!counterUpdated) {
586
+ res.status(401).send({
587
+ error: "Passkey assertion was already used.",
588
+ });
589
+ return;
590
+ }
576
591
 
577
592
  const user = await db.retrieveUser(pending.userID);
578
593
  if (!user) {
@@ -593,3 +608,11 @@ export const getPasskeyRouter = (db: Database) => {
593
608
 
594
609
  return router;
595
610
  };
611
+
612
+ function logWebAuthnFailure(ceremony: string, err: unknown): void {
613
+ const requestId = crypto.randomUUID();
614
+ const message = err instanceof Error ? err.message : String(err);
615
+ console.warn(
616
+ `[spire] WebAuthn ${ceremony} verification failed requestId=${requestId} message=${message}`,
617
+ );
618
+ }
@@ -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
@@ -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
  }