@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.
Files changed (82) 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 -3
  6. package/dist/Spire.js +176 -92
  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 -33
  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/{passkeySecondFactor.d.ts → password.d.ts} +1 -1
  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 +53 -17
  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 +418 -294
  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 +9 -54
  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 -35
  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 +58 -25
  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/dist/server/passkeySecondFactor.js +0 -20
  80. package/dist/server/passkeySecondFactor.js.map +0 -1
  81. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
  82. 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 { passkeySecondFactorError } from "./server/passkeySecondFactor.js";
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 { getJwtSecret } from "./utils/jwtSecret.js";
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
- export { passkeySecondFactorError } from "./server/passkeySecondFactor.js";
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 = "7d";
40
- export const DEVICE_AUTH_JWT_EXPIRY = "7d";
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(1),
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().min(1),
67
- signKey: z.string().min(1),
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().min(1),
71
- signed: z.string().min(1),
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
- // Trust a single proxy hop (nginx / cloudflare / load balancer).
178
- // Required so `req.ip` and `express-rate-limit`'s keyGenerator see
179
- // the real client address via X-Forwarded-For. Never use `true`
180
- // that lets attackers spoof the header and bypass rate limiting.
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.push(token);
304
+ this.actionTokens.set(token.key, token);
261
305
  return token;
262
306
  }
263
307
  deleteActionToken(key) {
264
- const idx = this.actionTokens.indexOf(key);
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 = jwt.verify(authResult.data.token, getJwtSecret());
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, (req, res) => {
529
- jwt.sign({ user: req.user }, getJwtSecret(), { expiresIn: -1 });
530
- res.sendStatus(200);
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
- const passkeyError = await passkeySecondFactorError(this.db, user.userID, req.passkey?.passkeyID, "Passkey verification does not match this device.");
615
- if (passkeyError) {
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, username } = parsed.data;
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 passkeyError = await passkeySecondFactorError(this.db, userEntry.userID, req.passkey?.passkeyID, "Passkey verification does not match this account.");
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
- if (regPayload.username !== undefined &&
867
- !usernameRegex.test(regPayload.username)) {
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, regPayload.signKey),
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 passkeyError = await passkeySecondFactorError(this.db, existingUser.userID, req.passkey.passkeyID, "Passkey verification does not match this account.");
893
- if (passkeyError) {
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: passkeyError,
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 = jwt.sign({ user: censored }, getJwtSecret(), { expiresIn: JWT_EXPIRY });
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
- for (const rKey of this.actionTokens) {
1014
- if (rKey.key === key) {
1015
- if (rKey.scope !== scope) {
1016
- continue;
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
- return false;
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. Lookups in `Database.retrieveUser` also fold case
1033
- // at compare time so legacy mixed-case rows still resolve.
1034
- function normalizeRegistrationUsername(username, signKeyHex) {
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