@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/dist/Spire.js
CHANGED
|
@@ -12,32 +12,30 @@ import { fileURLToPath } from "node:url";
|
|
|
12
12
|
import express from "express";
|
|
13
13
|
import { XUtils } from "@vex-chat/crypto";
|
|
14
14
|
import { setCryptoProfile, xRandomBytes, xSignKeyPairFromSecret, xSignKeyPairFromSecretAsync, } from "@vex-chat/crypto";
|
|
15
|
-
import { MailWSSchema, RegistrationPayloadSchema, TokenScopes, UserSchema, } from "@vex-chat/types";
|
|
16
|
-
import jwt from "jsonwebtoken";
|
|
15
|
+
import { ACCOUNT_PASSWORD_MAX_LENGTH, MailWSSchema, RegistrationPayloadSchema, TokenScopes, UserSchema, } from "@vex-chat/types";
|
|
17
16
|
import morgan from "morgan";
|
|
18
17
|
import { stringify as uuidStringify } from "uuid";
|
|
19
18
|
import { WebSocketServer } from "ws";
|
|
20
19
|
import { z } from "zod/v4";
|
|
21
20
|
import { CallManager } from "./CallManager.js";
|
|
22
21
|
import { ClientManager } from "./ClientManager.js";
|
|
23
|
-
import { Database, hashPasswordArgon2, verifyPassword } from "./Database.js";
|
|
22
|
+
import { Database, hashPasswordArgon2, MAX_ACTIVE_DEVICES_PER_USER, validateAccountPassword, verifyPassword, } from "./Database.js";
|
|
24
23
|
import { resolveIceServersFromEnv } from "./IceServers.js";
|
|
25
24
|
import { NotificationService } from "./NotificationService.js";
|
|
26
25
|
import { initApp, protect } from "./server/index.js";
|
|
27
26
|
import { MailIngressValidationError, validateMailIngress, } from "./server/mailIngress.js";
|
|
28
|
-
import {
|
|
29
|
-
import { authLimiter, devApiKeySkipsRateLimits } from "./server/rateLimit.js";
|
|
27
|
+
import { accountAuthLimiter, authLimiter, devApiKeySkipsRateLimits, } from "./server/rateLimit.js";
|
|
30
28
|
import { createPendingDeviceEnrollmentRequest } from "./server/user.js";
|
|
31
29
|
import { censorUser, getParam, getUser } from "./server/utils.js";
|
|
32
30
|
import { resolveSpireListenPort } from "./spireListenPort.js";
|
|
33
|
-
import {
|
|
31
|
+
import { signAuthJwt, verifyAuthJwt } from "./utils/authJwt.js";
|
|
34
32
|
import { msgpack } from "./utils/msgpack.js";
|
|
33
|
+
import { verifyDevicePayloadPreKeySignature } from "./utils/preKeySignature.js";
|
|
35
34
|
import { spireXSignOpenAsync } from "./utils/spireXSignOpenAsync.js";
|
|
36
|
-
|
|
37
|
-
// expiry of regkeys = 24hr
|
|
35
|
+
// One-use action tokens expire after ten minutes.
|
|
38
36
|
export const TOKEN_EXPIRY = 1000 * 60 * 10;
|
|
39
|
-
export const JWT_EXPIRY = "
|
|
40
|
-
export const DEVICE_AUTH_JWT_EXPIRY = "
|
|
37
|
+
export const JWT_EXPIRY = "1h";
|
|
38
|
+
export const DEVICE_AUTH_JWT_EXPIRY = "1h";
|
|
41
39
|
/**
|
|
42
40
|
* Passkey-scoped JWTs grant destructive admin powers (delete a
|
|
43
41
|
* device, recover a device enrollment) without further user
|
|
@@ -48,6 +46,7 @@ export const JWT_EXPIRY_PASSKEY = "5m";
|
|
|
48
46
|
const DEVICE_CHALLENGE_EXPIRY = 1000 * 60; // 60 seconds
|
|
49
47
|
// 3-19 chars long
|
|
50
48
|
const usernameRegex = /^(\w{3,19})$/;
|
|
49
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
51
50
|
// ── Zod schemas for trust-boundary validation ──────────────────────────
|
|
52
51
|
const wsAuthMsg = z.object({
|
|
53
52
|
token: z.string().min(1),
|
|
@@ -56,22 +55,58 @@ const wsAuthMsg = z.object({
|
|
|
56
55
|
const jwtPayload = z.object({
|
|
57
56
|
bearerToken: z.string().optional(),
|
|
58
57
|
exp: z.number().optional(),
|
|
58
|
+
scope: z.literal("user"),
|
|
59
59
|
user: UserSchema,
|
|
60
60
|
});
|
|
61
61
|
const authPayload = z.object({
|
|
62
|
-
password: z.string().min(1),
|
|
63
|
-
username: z.string().min(
|
|
62
|
+
password: z.string().min(1).max(ACCOUNT_PASSWORD_MAX_LENGTH),
|
|
63
|
+
username: z.string().trim().min(3).max(19).regex(usernameRegex),
|
|
64
|
+
});
|
|
65
|
+
const boundedRegistrationPayload = z.object({
|
|
66
|
+
deviceName: z.string().trim().min(1).max(100),
|
|
67
|
+
intent: z.enum(["create-account", "enroll-device"]),
|
|
68
|
+
password: z.string().max(ACCOUNT_PASSWORD_MAX_LENGTH).optional(),
|
|
69
|
+
preKey: z
|
|
70
|
+
.string()
|
|
71
|
+
.min(2)
|
|
72
|
+
.max(8192)
|
|
73
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
74
|
+
preKeyIndex: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
75
|
+
preKeySignature: z
|
|
76
|
+
.string()
|
|
77
|
+
.min(2)
|
|
78
|
+
.max(16_384)
|
|
79
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
80
|
+
signed: z
|
|
81
|
+
.string()
|
|
82
|
+
.min(2)
|
|
83
|
+
.max(8192)
|
|
84
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
85
|
+
signKey: z
|
|
86
|
+
.string()
|
|
87
|
+
.min(2)
|
|
88
|
+
.max(8192)
|
|
89
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
90
|
+
username: z.string().trim().min(3).max(19).regex(usernameRegex).optional(),
|
|
64
91
|
});
|
|
65
92
|
const deviceAuthPayload = z.object({
|
|
66
|
-
deviceID: z.string().
|
|
67
|
-
signKey: z
|
|
93
|
+
deviceID: z.string().regex(uuidRegex),
|
|
94
|
+
signKey: z
|
|
95
|
+
.string()
|
|
96
|
+
.min(64)
|
|
97
|
+
.max(8192)
|
|
98
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
68
99
|
});
|
|
69
100
|
const deviceVerifyPayload = z.object({
|
|
70
|
-
challengeID: z.string().
|
|
71
|
-
signed: z
|
|
101
|
+
challengeID: z.string().regex(uuidRegex),
|
|
102
|
+
signed: z
|
|
103
|
+
.string()
|
|
104
|
+
.min(2)
|
|
105
|
+
.max(16_384)
|
|
106
|
+
.regex(/^(?:[0-9a-fA-F]{2})+$/),
|
|
72
107
|
});
|
|
73
108
|
const mailPostPayload = z.object({
|
|
74
|
-
header: z.custom((val) => val instanceof Uint8Array),
|
|
109
|
+
header: z.custom((val) => val instanceof Uint8Array && val.byteLength === 32),
|
|
75
110
|
mail: MailWSSchema,
|
|
76
111
|
});
|
|
77
112
|
const MAIL_BATCH_MAX_ITEMS = 256;
|
|
@@ -80,9 +115,9 @@ const mailBatchPostPayload = z.object({
|
|
|
80
115
|
});
|
|
81
116
|
const notificationSubscribePayload = z.object({
|
|
82
117
|
channel: z.literal("expo"),
|
|
83
|
-
events: z.array(z.string().min(1)).default(["mail"]),
|
|
118
|
+
events: z.array(z.string().min(1).max(64)).max(32).default(["mail"]),
|
|
84
119
|
platform: z.enum(["android", "ios", "web"]).optional(),
|
|
85
|
-
token: z.string().min(1),
|
|
120
|
+
token: z.string().min(1).max(4096),
|
|
86
121
|
});
|
|
87
122
|
const directories = ["files", "avatars", "emoji"];
|
|
88
123
|
for (const dir of directories) {
|
|
@@ -124,6 +159,16 @@ const getCommitSha = () => {
|
|
|
124
159
|
return "unknown";
|
|
125
160
|
}
|
|
126
161
|
};
|
|
162
|
+
export function resolveTrustProxyHops(value) {
|
|
163
|
+
if (value === undefined || value.trim() === "") {
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
const hops = Number(value);
|
|
167
|
+
if (!Number.isInteger(hops) || hops < 0 || hops > 10) {
|
|
168
|
+
throw new Error("SPIRE_TRUST_PROXY_HOPS must be an integer from 0 to 10.");
|
|
169
|
+
}
|
|
170
|
+
return hops;
|
|
171
|
+
}
|
|
127
172
|
/**
|
|
128
173
|
* Masks identifying material in the access-log URL: hyphenated UUIDs, and
|
|
129
174
|
* `/device/...` path segments that hold public-key material (32B ed25519 hex, or
|
|
@@ -138,7 +183,7 @@ function redactAccessLogUrl(url) {
|
|
|
138
183
|
/** FIPS: sign key loaded inside `Spire.createAsync` before the constructor runs. */
|
|
139
184
|
let pendingFipsKeyPair = null;
|
|
140
185
|
export class Spire extends EventEmitter {
|
|
141
|
-
actionTokens =
|
|
186
|
+
actionTokens = new Map();
|
|
142
187
|
api = express();
|
|
143
188
|
calls;
|
|
144
189
|
clients = [];
|
|
@@ -174,12 +219,10 @@ export class Spire extends EventEmitter {
|
|
|
174
219
|
setCryptoProfile(this.cryptoProfile);
|
|
175
220
|
this.signKeys = xSignKeyPairFromSecret(XUtils.decodeHex(SK));
|
|
176
221
|
}
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
// If spire is deployed without a proxy, set this to 0 instead.
|
|
182
|
-
this.api.set("trust proxy", 1);
|
|
222
|
+
// Proxy trust must match the deployment topology. Defaulting to zero
|
|
223
|
+
// prevents direct deployments from accepting attacker-supplied
|
|
224
|
+
// X-Forwarded-For values and bypassing IP rate limits.
|
|
225
|
+
this.api.set("trust proxy", resolveTrustProxyHops(process.env["SPIRE_TRUST_PROXY_HOPS"]));
|
|
183
226
|
this.api.disable("etag");
|
|
184
227
|
this.db = new Database(options);
|
|
185
228
|
this.calls = new CallManager(this.db, this.notify.bind(this));
|
|
@@ -252,19 +295,17 @@ export class Spire extends EventEmitter {
|
|
|
252
295
|
}
|
|
253
296
|
}
|
|
254
297
|
createActionToken(scope) {
|
|
298
|
+
this.pruneActionTokens();
|
|
255
299
|
const token = {
|
|
256
300
|
key: crypto.randomUUID(),
|
|
257
301
|
scope,
|
|
258
302
|
time: new Date().toISOString(),
|
|
259
303
|
};
|
|
260
|
-
this.actionTokens.
|
|
304
|
+
this.actionTokens.set(token.key, token);
|
|
261
305
|
return token;
|
|
262
306
|
}
|
|
263
307
|
deleteActionToken(key) {
|
|
264
|
-
|
|
265
|
-
if (idx !== -1) {
|
|
266
|
-
this.actionTokens.splice(idx, 1);
|
|
267
|
-
}
|
|
308
|
+
this.actionTokens.delete(key.key);
|
|
268
309
|
}
|
|
269
310
|
disconnectDevices(deviceIDs) {
|
|
270
311
|
if (deviceIDs.length === 0) {
|
|
@@ -327,7 +368,7 @@ export class Spire extends EventEmitter {
|
|
|
327
368
|
throw new Error("Expected { type: 'auth', token }, got: " +
|
|
328
369
|
JSON.stringify(authResult.error.issues));
|
|
329
370
|
}
|
|
330
|
-
const result =
|
|
371
|
+
const result = verifyAuthJwt(authResult.data.token);
|
|
331
372
|
const jwtResult = jwtPayload.safeParse(result);
|
|
332
373
|
if (!jwtResult.success) {
|
|
333
374
|
throw new Error("Invalid JWT payload: " +
|
|
@@ -525,9 +566,10 @@ export class Spire extends EventEmitter {
|
|
|
525
566
|
res.status(500).json({ error: msg, ok: false });
|
|
526
567
|
}
|
|
527
568
|
});
|
|
528
|
-
this.api.post("/goodbye", protect, (
|
|
529
|
-
|
|
530
|
-
|
|
569
|
+
this.api.post("/goodbye", protect, (_req, res) => {
|
|
570
|
+
// Access tokens are stateless. Clients clear them locally; this
|
|
571
|
+
// endpoint exists only as a clean session boundary for callers.
|
|
572
|
+
res.sendStatus(204);
|
|
531
573
|
});
|
|
532
574
|
// ── Device-key auth ──────────────────────────────────────────
|
|
533
575
|
this.api.post("/auth/device", authLimiter, async (req, res) => {
|
|
@@ -611,18 +653,8 @@ export class Spire extends EventEmitter {
|
|
|
611
653
|
.status(404)
|
|
612
654
|
.send({ error: "Device owner not found." });
|
|
613
655
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const body = {
|
|
617
|
-
error: passkeyError,
|
|
618
|
-
};
|
|
619
|
-
if (passkeyError === "Passkey verification required.") {
|
|
620
|
-
body.username = user.username;
|
|
621
|
-
}
|
|
622
|
-
return res.status(403).send(body);
|
|
623
|
-
}
|
|
624
|
-
// Issue short-lived JWT (1 hour, not 7 days)
|
|
625
|
-
const token = jwt.sign({ user: censorUser(user) }, getJwtSecret(), { expiresIn: DEVICE_AUTH_JWT_EXPIRY });
|
|
656
|
+
// Device proof restores the same bounded account session as password login.
|
|
657
|
+
const token = signAuthJwt({ scope: "user", user: censorUser(user) }, DEVICE_AUTH_JWT_EXPIRY);
|
|
626
658
|
return res.send(msgpack.encode({ token, user: censorUser(user) }));
|
|
627
659
|
}
|
|
628
660
|
catch (_err) {
|
|
@@ -811,7 +843,7 @@ export class Spire extends EventEmitter {
|
|
|
811
843
|
});
|
|
812
844
|
res.sendStatus(removed ? 204 : 404);
|
|
813
845
|
});
|
|
814
|
-
this.api.post("/auth", authLimiter, async (req, res) => {
|
|
846
|
+
this.api.post("/auth", authLimiter, accountAuthLimiter, async (req, res) => {
|
|
815
847
|
const parsed = authPayload.safeParse(req.body);
|
|
816
848
|
if (!parsed.success) {
|
|
817
849
|
res.status(400).json({
|
|
@@ -819,10 +851,12 @@ export class Spire extends EventEmitter {
|
|
|
819
851
|
});
|
|
820
852
|
return;
|
|
821
853
|
}
|
|
822
|
-
const { password
|
|
854
|
+
const { password } = parsed.data;
|
|
855
|
+
const username = parsed.data.username.toLowerCase();
|
|
823
856
|
try {
|
|
824
857
|
const userEntry = await this.db.retrieveUser(username);
|
|
825
858
|
if (!userEntry) {
|
|
859
|
+
await verifyPassword(password, null);
|
|
826
860
|
res.sendStatus(401);
|
|
827
861
|
return;
|
|
828
862
|
}
|
|
@@ -835,16 +869,7 @@ export class Spire extends EventEmitter {
|
|
|
835
869
|
const newHash = await hashPasswordArgon2(password);
|
|
836
870
|
await this.db.rehashPassword(userEntry.userID, newHash);
|
|
837
871
|
}
|
|
838
|
-
const
|
|
839
|
-
if (passkeyError) {
|
|
840
|
-
res.status(403).send({
|
|
841
|
-
error: passkeyError,
|
|
842
|
-
});
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
const token = jwt.sign({ user: censorUser(userEntry) }, getJwtSecret(), { expiresIn: JWT_EXPIRY });
|
|
846
|
-
// just to make sure
|
|
847
|
-
jwt.verify(token, getJwtSecret());
|
|
872
|
+
const token = signAuthJwt({ scope: "user", user: censorUser(userEntry) }, JWT_EXPIRY);
|
|
848
873
|
res.send(msgpack.encode({ token, user: censorUser(userEntry) }));
|
|
849
874
|
}
|
|
850
875
|
catch (_err) {
|
|
@@ -852,7 +877,7 @@ export class Spire extends EventEmitter {
|
|
|
852
877
|
res.sendStatus(500);
|
|
853
878
|
}
|
|
854
879
|
});
|
|
855
|
-
this.api.post("/register", authLimiter, async (req, res) => {
|
|
880
|
+
this.api.post("/register", authLimiter, accountAuthLimiter, async (req, res) => {
|
|
856
881
|
try {
|
|
857
882
|
const regParsed = RegistrationPayloadSchema.safeParse(req.body);
|
|
858
883
|
if (!regParsed.success) {
|
|
@@ -863,8 +888,15 @@ export class Spire extends EventEmitter {
|
|
|
863
888
|
return;
|
|
864
889
|
}
|
|
865
890
|
const regPayload = regParsed.data;
|
|
866
|
-
|
|
867
|
-
|
|
891
|
+
const bounded = boundedRegistrationPayload.safeParse(regPayload);
|
|
892
|
+
if (!bounded.success) {
|
|
893
|
+
res.status(400).json({
|
|
894
|
+
error: "Invalid registration payload",
|
|
895
|
+
issues: bounded.error.issues,
|
|
896
|
+
});
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (!usernameRegex.test(regPayload.username)) {
|
|
868
900
|
res.status(400).send({
|
|
869
901
|
error: "Username must be between three and nineteen letters, digits, or underscores.",
|
|
870
902
|
});
|
|
@@ -872,13 +904,34 @@ export class Spire extends EventEmitter {
|
|
|
872
904
|
}
|
|
873
905
|
const normalizedPayload = {
|
|
874
906
|
...regPayload,
|
|
875
|
-
username: normalizeRegistrationUsername(regPayload.username
|
|
907
|
+
username: normalizeRegistrationUsername(regPayload.username),
|
|
876
908
|
};
|
|
877
909
|
const regKey = await spireXSignOpenAsync(XUtils.decodeHex(normalizedPayload.signed), XUtils.decodeHex(normalizedPayload.signKey));
|
|
878
910
|
if (regKey &&
|
|
879
911
|
regKey.length === 16 &&
|
|
880
912
|
this.validateToken(uuidStringify(regKey), TokenScopes.Register)) {
|
|
913
|
+
if (!(await verifyDevicePayloadPreKeySignature(normalizedPayload))) {
|
|
914
|
+
res.status(400).send({
|
|
915
|
+
error: "Signed prekey signature is invalid.",
|
|
916
|
+
});
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
881
919
|
const existingUser = await this.db.retrieveUser(normalizedPayload.username);
|
|
920
|
+
if (normalizedPayload.intent === "create-account" &&
|
|
921
|
+
existingUser) {
|
|
922
|
+
res.status(409).send({
|
|
923
|
+
error: "Username is already registered. Sign in instead.",
|
|
924
|
+
});
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (normalizedPayload.intent === "enroll-device" &&
|
|
928
|
+
!existingUser) {
|
|
929
|
+
await verifyPassword(normalizedPayload.password ?? "", null);
|
|
930
|
+
res.status(401).send({
|
|
931
|
+
error: "Invalid username or password.",
|
|
932
|
+
});
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
882
935
|
if (existingUser) {
|
|
883
936
|
const existingBySignKey = await this.db.retrieveDevice(normalizedPayload.signKey);
|
|
884
937
|
if (existingBySignKey) {
|
|
@@ -889,15 +942,46 @@ export class Spire extends EventEmitter {
|
|
|
889
942
|
}
|
|
890
943
|
let requesterPasskeyID;
|
|
891
944
|
if (req.passkey?.passkeyID) {
|
|
892
|
-
const
|
|
893
|
-
if (
|
|
945
|
+
const passkey = await this.db.retrievePasskeyInternal(req.passkey.passkeyID);
|
|
946
|
+
if (!passkey ||
|
|
947
|
+
passkey.userID !== existingUser.userID) {
|
|
894
948
|
res.status(403).send({
|
|
895
|
-
error:
|
|
949
|
+
error: "Passkey verification does not match this account.",
|
|
896
950
|
});
|
|
897
951
|
return;
|
|
898
952
|
}
|
|
899
953
|
requesterPasskeyID = req.passkey.passkeyID;
|
|
900
954
|
}
|
|
955
|
+
else {
|
|
956
|
+
if (typeof normalizedPayload.password !==
|
|
957
|
+
"string" ||
|
|
958
|
+
normalizedPayload.password.trim().length ===
|
|
959
|
+
0) {
|
|
960
|
+
res.status(401).send({
|
|
961
|
+
error: "Password is required to add this device.",
|
|
962
|
+
});
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const { needsRehash, valid } = await verifyPassword(normalizedPayload.password, existingUser);
|
|
966
|
+
if (!valid) {
|
|
967
|
+
res.sendStatus(401);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (needsRehash) {
|
|
971
|
+
const newHash = await hashPasswordArgon2(normalizedPayload.password);
|
|
972
|
+
await this.db.rehashPassword(existingUser.userID, newHash);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const activeDevices = await this.db.retrieveUserDeviceList([
|
|
976
|
+
existingUser.userID,
|
|
977
|
+
]);
|
|
978
|
+
if (activeDevices.length >=
|
|
979
|
+
MAX_ACTIVE_DEVICES_PER_USER) {
|
|
980
|
+
res.status(409).send({
|
|
981
|
+
error: `Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices. Remove an old device before adding another.`,
|
|
982
|
+
});
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
901
985
|
const pendingResponse = createPendingDeviceEnrollmentRequest(existingUser.userID, normalizedPayload, this.notify.bind(this), {
|
|
902
986
|
deferOwnerNotification: true,
|
|
903
987
|
...(requesterPasskeyID
|
|
@@ -914,6 +998,13 @@ export class Spire extends EventEmitter {
|
|
|
914
998
|
});
|
|
915
999
|
return;
|
|
916
1000
|
}
|
|
1001
|
+
const passwordError = validateAccountPassword(normalizedPayload.password, normalizedPayload.username);
|
|
1002
|
+
if (passwordError) {
|
|
1003
|
+
res.status(400).send({
|
|
1004
|
+
error: passwordError,
|
|
1005
|
+
});
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
917
1008
|
const [user, err] = await this.db.createUser(regKey, normalizedPayload);
|
|
918
1009
|
if (err !== null) {
|
|
919
1010
|
const errCode = "code" in err && typeof err.code === "string"
|
|
@@ -952,8 +1043,7 @@ export class Spire extends EventEmitter {
|
|
|
952
1043
|
return;
|
|
953
1044
|
}
|
|
954
1045
|
const censored = censorUser(user);
|
|
955
|
-
const token =
|
|
956
|
-
jwt.verify(token, getJwtSecret());
|
|
1046
|
+
const token = signAuthJwt({ scope: "user", user: censored }, JWT_EXPIRY);
|
|
957
1047
|
res.send(msgpack.encode({
|
|
958
1048
|
device,
|
|
959
1049
|
token,
|
|
@@ -1003,6 +1093,15 @@ export class Spire extends EventEmitter {
|
|
|
1003
1093
|
...(deviceID ? { deviceID } : {}),
|
|
1004
1094
|
});
|
|
1005
1095
|
}
|
|
1096
|
+
pruneActionTokens(now = Date.now()) {
|
|
1097
|
+
for (const [key, token] of this.actionTokens) {
|
|
1098
|
+
const createdAt = new Date(token.time).getTime();
|
|
1099
|
+
if (!Number.isFinite(createdAt) ||
|
|
1100
|
+
now - createdAt >= TOKEN_EXPIRY) {
|
|
1101
|
+
this.actionTokens.delete(key);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1006
1105
|
removeClient(client) {
|
|
1007
1106
|
const idx = this.clients.indexOf(client);
|
|
1008
1107
|
if (idx !== -1) {
|
|
@@ -1010,36 +1109,21 @@ export class Spire extends EventEmitter {
|
|
|
1010
1109
|
}
|
|
1011
1110
|
}
|
|
1012
1111
|
validateToken(key, scope) {
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
}
|
|
1018
|
-
const age = Date.now() - new Date(rKey.time).getTime();
|
|
1019
|
-
if (age < TOKEN_EXPIRY) {
|
|
1020
|
-
this.deleteActionToken(rKey);
|
|
1021
|
-
return true;
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1112
|
+
this.pruneActionTokens();
|
|
1113
|
+
const token = this.actionTokens.get(key);
|
|
1114
|
+
if (!token || token.scope !== scope) {
|
|
1115
|
+
return false;
|
|
1024
1116
|
}
|
|
1025
|
-
|
|
1117
|
+
this.deleteActionToken(token);
|
|
1118
|
+
return true;
|
|
1026
1119
|
}
|
|
1027
1120
|
}
|
|
1028
1121
|
// Usernames are case-insensitive at the protocol level — `User` and
|
|
1029
1122
|
// `user` must resolve to the same account. We canonicalize to
|
|
1030
1123
|
// lowercase at the registration boundary so the persisted row, the
|
|
1031
1124
|
// UNIQUE index, and every downstream lookup all agree on a single
|
|
1032
|
-
// representation.
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
const trimmed = username?.trim().toLowerCase();
|
|
1036
|
-
if (trimmed && trimmed.length > 0) {
|
|
1037
|
-
return trimmed;
|
|
1038
|
-
}
|
|
1039
|
-
const seed = signKeyHex
|
|
1040
|
-
.toLowerCase()
|
|
1041
|
-
.replace(/[^a-f0-9]/g, "")
|
|
1042
|
-
.slice(0, 12);
|
|
1043
|
-
return `key_${seed}`;
|
|
1125
|
+
// representation. `Database.retrieveUser` applies the same normalization.
|
|
1126
|
+
function normalizeRegistrationUsername(username) {
|
|
1127
|
+
return username.trim().toLowerCase();
|
|
1044
1128
|
}
|
|
1045
1129
|
//# sourceMappingURL=Spire.js.map
|