@vex-chat/spire 3.0.0 → 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.
- package/README.md +9 -8
- package/dist/Database.d.ts +12 -11
- package/dist/Database.js +161 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -3
- package/dist/Spire.js +176 -92
- 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 +30 -4
- package/dist/server/avatar.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 +157 -41
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +41 -33
- 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/{passkeySecondFactor.d.ts → password.d.ts} +1 -1
- 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/user.d.ts +1 -1
- package/dist/server/user.js +53 -17
- 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 +211 -152
- package/src/Spire.ts +418 -294
- package/src/__tests__/Database.spec.ts +126 -3
- 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 +9 -54
- 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/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 +29 -4
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +286 -111
- package/src/server/passkey.ts +51 -35
- 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/user.ts +58 -25
- 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/dist/server/passkeySecondFactor.js +0 -20
- package/dist/server/passkeySecondFactor.js.map +0 -1
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
- package/src/server/passkeySecondFactor.ts +0 -27
package/src/server/passkey.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|
-
|
|
289
|
-
if (!req.device && existing.length > 0) {
|
|
285
|
+
if (!req.device || req.device.owner !== userID) {
|
|
290
286
|
res.status(401).send({
|
|
291
|
-
error: "Adding
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
328
|
-
err instanceof Error ? err.message : String(err);
|
|
327
|
+
logWebAuthnFailure("registration", err);
|
|
329
328
|
res.status(400).send({
|
|
330
|
-
error: "Passkey attestation
|
|
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",
|
|
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,18 +401,17 @@ 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);
|
|
408
413
|
return;
|
|
409
414
|
}
|
|
410
|
-
const passkeys = await db.retrievePasskeysByUser(userID);
|
|
411
|
-
if (passkeys.length <= 1) {
|
|
412
|
-
res.status(400).send({
|
|
413
|
-
error: "You can't delete your last passkey.",
|
|
414
|
-
});
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
415
|
await db.deletePasskey(passkeyID);
|
|
418
416
|
res.sendStatus(200);
|
|
419
417
|
},
|
|
@@ -463,7 +461,7 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
463
461
|
const options = await generateAuthenticationOptions({
|
|
464
462
|
allowCredentials: allowCredentials.filter((c) => c.id.length > 0),
|
|
465
463
|
rpID,
|
|
466
|
-
userVerification: "
|
|
464
|
+
userVerification: "required",
|
|
467
465
|
});
|
|
468
466
|
|
|
469
467
|
pruneAuthentications();
|
|
@@ -548,13 +546,13 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
548
546
|
expectedChallenge: pending.challenge,
|
|
549
547
|
expectedOrigin,
|
|
550
548
|
expectedRPID: rpID,
|
|
551
|
-
requireUserVerification:
|
|
549
|
+
requireUserVerification: true,
|
|
552
550
|
response: assertion,
|
|
553
551
|
});
|
|
554
552
|
} catch (err: unknown) {
|
|
555
|
-
|
|
553
|
+
logWebAuthnFailure("authentication", err);
|
|
556
554
|
res.status(401).send({
|
|
557
|
-
error: "Passkey assertion
|
|
555
|
+
error: "Passkey assertion could not be verified.",
|
|
558
556
|
});
|
|
559
557
|
return;
|
|
560
558
|
}
|
|
@@ -579,7 +577,17 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
579
577
|
return;
|
|
580
578
|
}
|
|
581
579
|
|
|
582
|
-
await db.markPasskeyUsed(
|
|
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
|
+
}
|
|
583
591
|
|
|
584
592
|
const user = await db.retrieveUser(pending.userID);
|
|
585
593
|
if (!user) {
|
|
@@ -600,3 +608,11 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
600
608
|
|
|
601
609
|
return router;
|
|
602
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
|
|
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
|
package/src/server/user.ts
CHANGED
|
@@ -24,11 +24,12 @@ 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";
|
|
31
|
-
import { passkeySecondFactorError } from "./passkeySecondFactor.ts";
|
|
32
33
|
import { censorUser, getParam, getUser } from "./utils.ts";
|
|
33
34
|
import { buildAndroidApkKeyHashOrigins } from "./wellKnown.ts";
|
|
34
35
|
|
|
@@ -492,6 +493,7 @@ export const getUserRouter = (
|
|
|
492
493
|
data?: unknown,
|
|
493
494
|
deviceID?: string,
|
|
494
495
|
) => void,
|
|
496
|
+
disconnectDevices?: (deviceIDs: string[]) => void,
|
|
495
497
|
) => {
|
|
496
498
|
const router = express.Router();
|
|
497
499
|
|
|
@@ -582,7 +584,7 @@ export const getUserRouter = (
|
|
|
582
584
|
authenticatorSelection: {
|
|
583
585
|
requireResidentKey: false,
|
|
584
586
|
residentKey: "preferred",
|
|
585
|
-
userVerification: "
|
|
587
|
+
userVerification: "required",
|
|
586
588
|
},
|
|
587
589
|
excludeCredentials: [],
|
|
588
590
|
rpID,
|
|
@@ -676,16 +678,20 @@ export const getUserRouter = (
|
|
|
676
678
|
expectedChallenge: passkeyRegistration.challenge,
|
|
677
679
|
expectedOrigin,
|
|
678
680
|
expectedRPID: rpID,
|
|
679
|
-
requireUserVerification:
|
|
681
|
+
requireUserVerification: true,
|
|
680
682
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
|
|
681
683
|
response: parsed.data
|
|
682
684
|
.response as unknown as RegistrationResponseJSON,
|
|
683
685
|
});
|
|
684
686
|
} catch (err: unknown) {
|
|
687
|
+
const requestId = crypto.randomUUID();
|
|
685
688
|
const message =
|
|
686
689
|
err instanceof Error ? err.message : String(err);
|
|
690
|
+
console.warn(
|
|
691
|
+
`[spire] pending-device WebAuthn registration failed requestId=${requestId} message=${message}`,
|
|
692
|
+
);
|
|
687
693
|
res.status(400).send({
|
|
688
|
-
error: "Passkey attestation
|
|
694
|
+
error: "Passkey attestation could not be verified.",
|
|
689
695
|
});
|
|
690
696
|
return;
|
|
691
697
|
}
|
|
@@ -812,6 +818,10 @@ export const getUserRouter = (
|
|
|
812
818
|
|
|
813
819
|
router.get("/:id/permissions", protect, async (req, res) => {
|
|
814
820
|
const userDetails = getUser(req);
|
|
821
|
+
if (getParam(req, "id") !== userDetails.userID) {
|
|
822
|
+
res.sendStatus(403);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
815
825
|
const permissions = await db.retrievePermissions(
|
|
816
826
|
userDetails.userID,
|
|
817
827
|
"all",
|
|
@@ -821,12 +831,20 @@ export const getUserRouter = (
|
|
|
821
831
|
|
|
822
832
|
router.get("/:id/servers", protect, async (req, res) => {
|
|
823
833
|
const userDetails = getUser(req);
|
|
834
|
+
if (getParam(req, "id") !== userDetails.userID) {
|
|
835
|
+
res.sendStatus(403);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
824
838
|
const servers = await db.retrieveServers(userDetails.userID);
|
|
825
839
|
res.send(msgpack.encode(servers));
|
|
826
840
|
});
|
|
827
841
|
|
|
828
842
|
router.get("/:id/servers/bootstrap", protect, async (req, res) => {
|
|
829
843
|
const userDetails = getUser(req);
|
|
844
|
+
if (getParam(req, "id") !== userDetails.userID) {
|
|
845
|
+
res.sendStatus(403);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
830
848
|
const payload = await db.retrieveServerChannelBootstrap(
|
|
831
849
|
userDetails.userID,
|
|
832
850
|
);
|
|
@@ -841,8 +859,14 @@ export const getUserRouter = (
|
|
|
841
859
|
return;
|
|
842
860
|
}
|
|
843
861
|
const userDetails = getUser(req);
|
|
844
|
-
|
|
845
|
-
|
|
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);
|
|
846
870
|
return;
|
|
847
871
|
}
|
|
848
872
|
const deviceList = await db.retrieveUserDeviceList([
|
|
@@ -856,6 +880,7 @@ export const getUserRouter = (
|
|
|
856
880
|
}
|
|
857
881
|
|
|
858
882
|
await db.deleteDevice(device.deviceID);
|
|
883
|
+
disconnectDevices?.([device.deviceID]);
|
|
859
884
|
res.sendStatus(200);
|
|
860
885
|
});
|
|
861
886
|
|
|
@@ -894,9 +919,21 @@ export const getUserRouter = (
|
|
|
894
919
|
}
|
|
895
920
|
|
|
896
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
|
+
}
|
|
897
928
|
const userDevices = await db.retrieveUserDeviceList([
|
|
898
929
|
userDetails.userID,
|
|
899
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
|
+
}
|
|
900
937
|
if (userDevices.length === 0) {
|
|
901
938
|
try {
|
|
902
939
|
const device = await db.createDevice(
|
|
@@ -914,14 +951,13 @@ export const getUserRouter = (
|
|
|
914
951
|
|
|
915
952
|
let requesterPasskeyID: string | undefined;
|
|
916
953
|
if (req.passkey?.passkeyID) {
|
|
917
|
-
const
|
|
918
|
-
db,
|
|
919
|
-
userDetails.userID,
|
|
954
|
+
const passkey = await db.retrievePasskeyInternal(
|
|
920
955
|
req.passkey.passkeyID,
|
|
921
|
-
"Passkey verification does not match this account.",
|
|
922
956
|
);
|
|
923
|
-
if (
|
|
924
|
-
res.status(403).send({
|
|
957
|
+
if (!passkey || passkey.userID !== userDetails.userID) {
|
|
958
|
+
res.status(403).send({
|
|
959
|
+
error: "Passkey verification does not match this account.",
|
|
960
|
+
});
|
|
925
961
|
return;
|
|
926
962
|
}
|
|
927
963
|
requesterPasskeyID = req.passkey.passkeyID;
|
|
@@ -1028,19 +1064,16 @@ export const getUserRouter = (
|
|
|
1028
1064
|
return;
|
|
1029
1065
|
}
|
|
1030
1066
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
if (passkeyError) {
|
|
1042
|
-
res.status(403).send({ error: passkeyError });
|
|
1043
|
-
return;
|
|
1067
|
+
const approvedByPasskeyID = pending.requesterPasskeyID;
|
|
1068
|
+
if (approvedByPasskeyID) {
|
|
1069
|
+
const passkey =
|
|
1070
|
+
await db.retrievePasskeyInternal(approvedByPasskeyID);
|
|
1071
|
+
if (!passkey || passkey.userID !== userID) {
|
|
1072
|
+
res.status(403).send({
|
|
1073
|
+
error: "Passkey verification does not match this account.",
|
|
1074
|
+
});
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1044
1077
|
}
|
|
1045
1078
|
|
|
1046
1079
|
const opened = await spireXSignOpenAsync(
|
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
|
+
}
|