@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/src/Spire.ts CHANGED
@@ -31,13 +31,13 @@ import {
31
31
  xSignKeyPairFromSecretAsync,
32
32
  } from "@vex-chat/crypto";
33
33
  import {
34
+ ACCOUNT_PASSWORD_MAX_LENGTH,
34
35
  MailWSSchema,
35
36
  RegistrationPayloadSchema,
36
37
  TokenScopes,
37
38
  UserSchema,
38
39
  } from "@vex-chat/types";
39
40
 
40
- import jwt from "jsonwebtoken";
41
41
  import morgan from "morgan";
42
42
  import { stringify as uuidStringify } from "uuid";
43
43
  import { WebSocketServer } from "ws";
@@ -45,7 +45,13 @@ import { z } from "zod/v4";
45
45
 
46
46
  import { CallManager } from "./CallManager.ts";
47
47
  import { ClientManager } from "./ClientManager.ts";
48
- import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
48
+ import {
49
+ Database,
50
+ hashPasswordArgon2,
51
+ MAX_ACTIVE_DEVICES_PER_USER,
52
+ validateAccountPassword,
53
+ verifyPassword,
54
+ } from "./Database.ts";
49
55
  import { resolveIceServersFromEnv } from "./IceServers.ts";
50
56
  import { NotificationService } from "./NotificationService.ts";
51
57
  import { initApp, protect } from "./server/index.ts";
@@ -53,21 +59,23 @@ import {
53
59
  MailIngressValidationError,
54
60
  validateMailIngress,
55
61
  } from "./server/mailIngress.ts";
56
- import { passkeySecondFactorError } from "./server/passkeySecondFactor.ts";
57
- import { authLimiter, devApiKeySkipsRateLimits } from "./server/rateLimit.ts";
62
+ import {
63
+ accountAuthLimiter,
64
+ authLimiter,
65
+ devApiKeySkipsRateLimits,
66
+ } from "./server/rateLimit.ts";
58
67
  import { createPendingDeviceEnrollmentRequest } from "./server/user.ts";
59
68
  import { censorUser, getParam, getUser } from "./server/utils.ts";
60
69
  import { resolveSpireListenPort } from "./spireListenPort.ts";
61
- import { getJwtSecret } from "./utils/jwtSecret.ts";
70
+ import { signAuthJwt, verifyAuthJwt } from "./utils/authJwt.ts";
62
71
  import { msgpack } from "./utils/msgpack.ts";
72
+ import { verifyDevicePayloadPreKeySignature } from "./utils/preKeySignature.ts";
63
73
  import { spireXSignOpenAsync } from "./utils/spireXSignOpenAsync.ts";
64
74
 
65
- export { passkeySecondFactorError } from "./server/passkeySecondFactor.ts";
66
-
67
- // expiry of regkeys = 24hr
75
+ // One-use action tokens expire after ten minutes.
68
76
  export const TOKEN_EXPIRY = 1000 * 60 * 10;
69
- export const JWT_EXPIRY = "7d";
70
- export const DEVICE_AUTH_JWT_EXPIRY = "7d";
77
+ export const JWT_EXPIRY = "1h";
78
+ export const DEVICE_AUTH_JWT_EXPIRY = "1h";
71
79
  /**
72
80
  * Passkey-scoped JWTs grant destructive admin powers (delete a
73
81
  * device, recover a device enrollment) without further user
@@ -79,6 +87,8 @@ const DEVICE_CHALLENGE_EXPIRY = 1000 * 60; // 60 seconds
79
87
 
80
88
  // 3-19 chars long
81
89
  const usernameRegex = /^(\w{3,19})$/;
90
+ const uuidRegex =
91
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
82
92
 
83
93
  // ── Zod schemas for trust-boundary validation ──────────────────────────
84
94
  const wsAuthMsg = z.object({
@@ -89,26 +99,65 @@ const wsAuthMsg = z.object({
89
99
  const jwtPayload = z.object({
90
100
  bearerToken: z.string().optional(),
91
101
  exp: z.number().optional(),
102
+ scope: z.literal("user"),
92
103
  user: UserSchema,
93
104
  });
94
105
 
95
106
  const authPayload = z.object({
96
- password: z.string().min(1),
97
- username: z.string().min(1),
107
+ password: z.string().min(1).max(ACCOUNT_PASSWORD_MAX_LENGTH),
108
+ username: z.string().trim().min(3).max(19).regex(usernameRegex),
109
+ });
110
+
111
+ const boundedRegistrationPayload = z.object({
112
+ deviceName: z.string().trim().min(1).max(100),
113
+ intent: z.enum(["create-account", "enroll-device"]),
114
+ password: z.string().max(ACCOUNT_PASSWORD_MAX_LENGTH).optional(),
115
+ preKey: z
116
+ .string()
117
+ .min(2)
118
+ .max(8192)
119
+ .regex(/^(?:[0-9a-fA-F]{2})+$/),
120
+ preKeyIndex: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
121
+ preKeySignature: z
122
+ .string()
123
+ .min(2)
124
+ .max(16_384)
125
+ .regex(/^(?:[0-9a-fA-F]{2})+$/),
126
+ signed: z
127
+ .string()
128
+ .min(2)
129
+ .max(8192)
130
+ .regex(/^(?:[0-9a-fA-F]{2})+$/),
131
+ signKey: z
132
+ .string()
133
+ .min(2)
134
+ .max(8192)
135
+ .regex(/^(?:[0-9a-fA-F]{2})+$/),
136
+ username: z.string().trim().min(3).max(19).regex(usernameRegex).optional(),
98
137
  });
99
138
 
100
139
  const deviceAuthPayload = z.object({
101
- deviceID: z.string().min(1),
102
- signKey: z.string().min(1),
140
+ deviceID: z.string().regex(uuidRegex),
141
+ signKey: z
142
+ .string()
143
+ .min(64)
144
+ .max(8192)
145
+ .regex(/^(?:[0-9a-fA-F]{2})+$/),
103
146
  });
104
147
 
105
148
  const deviceVerifyPayload = z.object({
106
- challengeID: z.string().min(1),
107
- signed: z.string().min(1),
149
+ challengeID: z.string().regex(uuidRegex),
150
+ signed: z
151
+ .string()
152
+ .min(2)
153
+ .max(16_384)
154
+ .regex(/^(?:[0-9a-fA-F]{2})+$/),
108
155
  });
109
156
 
110
157
  const mailPostPayload = z.object({
111
- header: z.custom<Uint8Array>((val) => val instanceof Uint8Array),
158
+ header: z.custom<Uint8Array>(
159
+ (val) => val instanceof Uint8Array && val.byteLength === 32,
160
+ ),
112
161
  mail: MailWSSchema,
113
162
  });
114
163
  const MAIL_BATCH_MAX_ITEMS = 256;
@@ -134,9 +183,9 @@ interface ValidatedMailBatchEntry {
134
183
 
135
184
  const notificationSubscribePayload = z.object({
136
185
  channel: z.literal("expo"),
137
- events: z.array(z.string().min(1)).default(["mail"]),
186
+ events: z.array(z.string().min(1).max(64)).max(32).default(["mail"]),
138
187
  platform: z.enum(["android", "ios", "web"]).optional(),
139
- token: z.string().min(1),
188
+ token: z.string().min(1).max(4096),
140
189
  });
141
190
 
142
191
  const directories = ["files", "avatars", "emoji"];
@@ -197,6 +246,19 @@ export interface SpireOptions {
197
246
  dbType?: "mysql" | "sqlite3" | "sqlite3mem" | "sqlite";
198
247
  }
199
248
 
249
+ export function resolveTrustProxyHops(value: string | undefined): number {
250
+ if (value === undefined || value.trim() === "") {
251
+ return 0;
252
+ }
253
+ const hops = Number(value);
254
+ if (!Number.isInteger(hops) || hops < 0 || hops > 10) {
255
+ throw new Error(
256
+ "SPIRE_TRUST_PROXY_HOPS must be an integer from 0 to 10.",
257
+ );
258
+ }
259
+ return hops;
260
+ }
261
+
200
262
  /**
201
263
  * Masks identifying material in the access-log URL: hyphenated UUIDs, and
202
264
  * `/device/...` path segments that hold public-key material (32B ed25519 hex, or
@@ -216,7 +278,7 @@ function redactAccessLogUrl(url: string): string {
216
278
  let pendingFipsKeyPair: KeyPair | null = null;
217
279
 
218
280
  export class Spire extends EventEmitter {
219
- private actionTokens: ActionToken[] = [];
281
+ private actionTokens = new Map<string, ActionToken>();
220
282
  private api = express();
221
283
  private calls: CallManager;
222
284
  private clients: ClientManager[] = [];
@@ -261,12 +323,13 @@ export class Spire extends EventEmitter {
261
323
  this.signKeys = xSignKeyPairFromSecret(XUtils.decodeHex(SK));
262
324
  }
263
325
 
264
- // Trust a single proxy hop (nginx / cloudflare / load balancer).
265
- // Required so `req.ip` and `express-rate-limit`'s keyGenerator see
266
- // the real client address via X-Forwarded-For. Never use `true`
267
- // that lets attackers spoof the header and bypass rate limiting.
268
- // If spire is deployed without a proxy, set this to 0 instead.
269
- this.api.set("trust proxy", 1);
326
+ // Proxy trust must match the deployment topology. Defaulting to zero
327
+ // prevents direct deployments from accepting attacker-supplied
328
+ // X-Forwarded-For values and bypassing IP rate limits.
329
+ this.api.set(
330
+ "trust proxy",
331
+ resolveTrustProxyHops(process.env["SPIRE_TRUST_PROXY_HOPS"]),
332
+ );
270
333
  this.api.disable("etag");
271
334
 
272
335
  this.db = new Database(options);
@@ -362,20 +425,18 @@ export class Spire extends EventEmitter {
362
425
  }
363
426
 
364
427
  private createActionToken(scope: TokenScopes): ActionToken {
428
+ this.pruneActionTokens();
365
429
  const token: ActionToken = {
366
430
  key: crypto.randomUUID(),
367
431
  scope,
368
432
  time: new Date().toISOString(),
369
433
  };
370
- this.actionTokens.push(token);
434
+ this.actionTokens.set(token.key, token);
371
435
  return token;
372
436
  }
373
437
 
374
438
  private deleteActionToken(key: ActionToken) {
375
- const idx = this.actionTokens.indexOf(key);
376
- if (idx !== -1) {
377
- this.actionTokens.splice(idx, 1);
378
- }
439
+ this.actionTokens.delete(key.key);
379
440
  }
380
441
 
381
442
  private disconnectDevices(deviceIDs: string[]): void {
@@ -457,10 +518,7 @@ export class Spire extends EventEmitter {
457
518
  JSON.stringify(authResult.error.issues),
458
519
  );
459
520
  }
460
- const result = jwt.verify(
461
- authResult.data.token,
462
- getJwtSecret(),
463
- );
521
+ const result = verifyAuthJwt(authResult.data.token);
464
522
  const jwtResult = jwtPayload.safeParse(result);
465
523
  if (!jwtResult.success) {
466
524
  throw new Error(
@@ -692,9 +750,10 @@ export class Spire extends EventEmitter {
692
750
  }
693
751
  });
694
752
 
695
- this.api.post("/goodbye", protect, (req, res) => {
696
- jwt.sign({ user: req.user }, getJwtSecret(), { expiresIn: -1 });
697
- res.sendStatus(200);
753
+ this.api.post("/goodbye", protect, (_req, res) => {
754
+ // Access tokens are stateless. Clients clear them locally; this
755
+ // endpoint exists only as a clean session boundary for callers.
756
+ res.sendStatus(204);
698
757
  });
699
758
 
700
759
  // ── Device-key auth ──────────────────────────────────────────
@@ -797,27 +856,10 @@ export class Spire extends EventEmitter {
797
856
  .send({ error: "Device owner not found." });
798
857
  }
799
858
 
800
- const passkeyError = await passkeySecondFactorError(
801
- this.db,
802
- user.userID,
803
- req.passkey?.passkeyID,
804
- "Passkey verification does not match this device.",
805
- );
806
- if (passkeyError) {
807
- const body: { error: string; username?: string } = {
808
- error: passkeyError,
809
- };
810
- if (passkeyError === "Passkey verification required.") {
811
- body.username = user.username;
812
- }
813
- return res.status(403).send(body);
814
- }
815
-
816
- // Issue short-lived JWT (1 hour, not 7 days)
817
- const token = jwt.sign(
818
- { user: censorUser(user) },
819
- getJwtSecret(),
820
- { expiresIn: DEVICE_AUTH_JWT_EXPIRY },
859
+ // Device proof restores the same bounded account session as password login.
860
+ const token = signAuthJwt(
861
+ { scope: "user", user: censorUser(user) },
862
+ DEVICE_AUTH_JWT_EXPIRY,
821
863
  );
822
864
  return res.send(
823
865
  msgpack.encode({ token, user: censorUser(user) }),
@@ -1077,247 +1119,336 @@ export class Spire extends EventEmitter {
1077
1119
  },
1078
1120
  );
1079
1121
 
1080
- this.api.post("/auth", authLimiter, async (req, res) => {
1081
- const parsed = authPayload.safeParse(req.body);
1082
- if (!parsed.success) {
1083
- res.status(400).json({
1084
- error: "Invalid credentials format",
1085
- });
1086
- return;
1087
- }
1088
- const { password, username } = parsed.data;
1089
-
1090
- try {
1091
- const userEntry = await this.db.retrieveUser(username);
1092
- if (!userEntry) {
1093
- res.sendStatus(401);
1094
- return;
1095
- }
1096
-
1097
- const { needsRehash, valid } = await verifyPassword(
1098
- password,
1099
- userEntry,
1100
- );
1101
-
1102
- if (!valid) {
1103
- res.sendStatus(401);
1122
+ this.api.post(
1123
+ "/auth",
1124
+ authLimiter,
1125
+ accountAuthLimiter,
1126
+ async (req, res) => {
1127
+ const parsed = authPayload.safeParse(req.body);
1128
+ if (!parsed.success) {
1129
+ res.status(400).json({
1130
+ error: "Invalid credentials format",
1131
+ });
1104
1132
  return;
1105
1133
  }
1134
+ const { password } = parsed.data;
1135
+ const username = parsed.data.username.toLowerCase();
1106
1136
 
1107
- if (needsRehash) {
1108
- const newHash = await hashPasswordArgon2(password);
1109
- await this.db.rehashPassword(userEntry.userID, newHash);
1110
- }
1137
+ try {
1138
+ const userEntry = await this.db.retrieveUser(username);
1139
+ if (!userEntry) {
1140
+ await verifyPassword(password, null);
1141
+ res.sendStatus(401);
1142
+ return;
1143
+ }
1111
1144
 
1112
- const passkeyError = await passkeySecondFactorError(
1113
- this.db,
1114
- userEntry.userID,
1115
- req.passkey?.passkeyID,
1116
- "Passkey verification does not match this account.",
1117
- );
1118
- if (passkeyError) {
1119
- res.status(403).send({
1120
- error: passkeyError,
1121
- });
1122
- return;
1123
- }
1145
+ const { needsRehash, valid } = await verifyPassword(
1146
+ password,
1147
+ userEntry,
1148
+ );
1124
1149
 
1125
- const token = jwt.sign(
1126
- { user: censorUser(userEntry) },
1127
- getJwtSecret(),
1128
- { expiresIn: JWT_EXPIRY },
1129
- );
1150
+ if (!valid) {
1151
+ res.sendStatus(401);
1152
+ return;
1153
+ }
1130
1154
 
1131
- // just to make sure
1132
- jwt.verify(token, getJwtSecret());
1155
+ if (needsRehash) {
1156
+ const newHash = await hashPasswordArgon2(password);
1157
+ await this.db.rehashPassword(userEntry.userID, newHash);
1158
+ }
1133
1159
 
1134
- res.send(
1135
- msgpack.encode({ token, user: censorUser(userEntry) }),
1136
- );
1137
- } catch (_err: unknown) {
1138
- // debugger: auth error
1139
- res.sendStatus(500);
1140
- }
1141
- });
1160
+ const token = signAuthJwt(
1161
+ { scope: "user", user: censorUser(userEntry) },
1162
+ JWT_EXPIRY,
1163
+ );
1142
1164
 
1143
- this.api.post("/register", authLimiter, async (req, res) => {
1144
- try {
1145
- const regParsed = RegistrationPayloadSchema.safeParse(req.body);
1146
- if (!regParsed.success) {
1147
- res.status(400).json({
1148
- error: "Invalid registration payload",
1149
- issues: regParsed.error.issues,
1150
- });
1151
- return;
1152
- }
1153
- const regPayload = regParsed.data;
1154
- if (
1155
- regPayload.username !== undefined &&
1156
- !usernameRegex.test(regPayload.username)
1157
- ) {
1158
- res.status(400).send({
1159
- error: "Username must be between three and nineteen letters, digits, or underscores.",
1160
- });
1161
- return;
1165
+ res.send(
1166
+ msgpack.encode({ token, user: censorUser(userEntry) }),
1167
+ );
1168
+ } catch (_err: unknown) {
1169
+ // debugger: auth error
1170
+ res.sendStatus(500);
1162
1171
  }
1163
- const normalizedPayload = {
1164
- ...regPayload,
1165
- username: normalizeRegistrationUsername(
1166
- regPayload.username,
1167
- regPayload.signKey,
1168
- ),
1169
- };
1170
-
1171
- const regKey = await spireXSignOpenAsync(
1172
- XUtils.decodeHex(normalizedPayload.signed),
1173
- XUtils.decodeHex(normalizedPayload.signKey),
1174
- );
1172
+ },
1173
+ );
1175
1174
 
1176
- if (
1177
- regKey &&
1178
- regKey.length === 16 &&
1179
- this.validateToken(
1180
- uuidStringify(regKey),
1181
- TokenScopes.Register,
1182
- )
1183
- ) {
1184
- const existingUser = await this.db.retrieveUser(
1185
- normalizedPayload.username,
1175
+ this.api.post(
1176
+ "/register",
1177
+ authLimiter,
1178
+ accountAuthLimiter,
1179
+ async (req, res) => {
1180
+ try {
1181
+ const regParsed = RegistrationPayloadSchema.safeParse(
1182
+ req.body,
1186
1183
  );
1187
- if (existingUser) {
1188
- const existingBySignKey = await this.db.retrieveDevice(
1189
- normalizedPayload.signKey,
1190
- );
1191
- if (existingBySignKey) {
1192
- res.status(400).send({
1193
- error: "Public key is already registered.",
1194
- });
1195
- return;
1196
- }
1197
- let requesterPasskeyID: string | undefined;
1198
- if (req.passkey?.passkeyID) {
1199
- const passkeyError = await passkeySecondFactorError(
1200
- this.db,
1201
- existingUser.userID,
1202
- req.passkey.passkeyID,
1203
- "Passkey verification does not match this account.",
1204
- );
1205
- if (passkeyError) {
1206
- res.status(403).send({
1207
- error: passkeyError,
1208
- });
1209
- return;
1210
- }
1211
- requesterPasskeyID = req.passkey.passkeyID;
1212
- }
1213
- const pendingResponse =
1214
- createPendingDeviceEnrollmentRequest(
1215
- existingUser.userID,
1216
- normalizedPayload,
1217
- this.notify.bind(this),
1218
- {
1219
- deferOwnerNotification: true,
1220
- ...(requesterPasskeyID
1221
- ? { requesterPasskeyID }
1222
- : {}),
1223
- },
1224
- );
1225
- res.status(202).send(msgpack.encode(pendingResponse));
1184
+ if (!regParsed.success) {
1185
+ res.status(400).json({
1186
+ error: "Invalid registration payload",
1187
+ issues: regParsed.error.issues,
1188
+ });
1226
1189
  return;
1227
1190
  }
1228
- if (
1229
- typeof normalizedPayload.password !== "string" ||
1230
- normalizedPayload.password.trim().length === 0
1231
- ) {
1191
+ const regPayload = regParsed.data;
1192
+ const bounded =
1193
+ boundedRegistrationPayload.safeParse(regPayload);
1194
+ if (!bounded.success) {
1195
+ res.status(400).json({
1196
+ error: "Invalid registration payload",
1197
+ issues: bounded.error.issues,
1198
+ });
1199
+ return;
1200
+ }
1201
+ if (!usernameRegex.test(regPayload.username)) {
1232
1202
  res.status(400).send({
1233
- error: "Password is required to register a new account.",
1203
+ error: "Username must be between three and nineteen letters, digits, or underscores.",
1234
1204
  });
1235
1205
  return;
1236
1206
  }
1237
- const [user, err] = await this.db.createUser(
1238
- regKey,
1239
- normalizedPayload,
1207
+ const normalizedPayload = {
1208
+ ...regPayload,
1209
+ username: normalizeRegistrationUsername(
1210
+ regPayload.username,
1211
+ ),
1212
+ };
1213
+
1214
+ const regKey = await spireXSignOpenAsync(
1215
+ XUtils.decodeHex(normalizedPayload.signed),
1216
+ XUtils.decodeHex(normalizedPayload.signKey),
1240
1217
  );
1241
- if (err !== null) {
1242
- const errCode =
1243
- "code" in err && typeof err.code === "string"
1244
- ? err.code
1245
- : undefined;
1246
- const errText = String(err);
1247
- const usernameConflict =
1248
- errText.includes("users_username_unique") ||
1249
- errText.includes("users.username");
1250
- const signKeyConflict =
1251
- errText.includes("devices_signKey_unique") ||
1252
- errText.includes("devices.signKey");
1253
- const isUniqueConstraint =
1254
- errCode === "ER_DUP_ENTRY" ||
1255
- errCode === "SQLITE_CONSTRAINT_UNIQUE" ||
1256
- errText.includes("UNIQUE constraint failed");
1257
- if (isUniqueConstraint && usernameConflict) {
1218
+
1219
+ if (
1220
+ regKey &&
1221
+ regKey.length === 16 &&
1222
+ this.validateToken(
1223
+ uuidStringify(regKey),
1224
+ TokenScopes.Register,
1225
+ )
1226
+ ) {
1227
+ if (
1228
+ !(await verifyDevicePayloadPreKeySignature(
1229
+ normalizedPayload,
1230
+ ))
1231
+ ) {
1258
1232
  res.status(400).send({
1259
- error: "Username is already registered.",
1233
+ error: "Signed prekey signature is invalid.",
1260
1234
  });
1261
1235
  return;
1262
1236
  }
1263
- if (isUniqueConstraint && signKeyConflict) {
1264
- res.status(400).send({
1265
- error: "Public key is already registered.",
1237
+ const existingUser = await this.db.retrieveUser(
1238
+ normalizedPayload.username,
1239
+ );
1240
+ if (
1241
+ normalizedPayload.intent === "create-account" &&
1242
+ existingUser
1243
+ ) {
1244
+ res.status(409).send({
1245
+ error: "Username is already registered. Sign in instead.",
1266
1246
  });
1267
1247
  return;
1268
1248
  }
1269
- res.sendStatus(500);
1270
- } else {
1271
- if (!user) {
1272
- res.sendStatus(500);
1249
+ if (
1250
+ normalizedPayload.intent === "enroll-device" &&
1251
+ !existingUser
1252
+ ) {
1253
+ await verifyPassword(
1254
+ normalizedPayload.password ?? "",
1255
+ null,
1256
+ );
1257
+ res.status(401).send({
1258
+ error: "Invalid username or password.",
1259
+ });
1273
1260
  return;
1274
1261
  }
1275
- const device = await this.db.retrieveDevice(
1276
- normalizedPayload.signKey,
1277
- );
1278
- if (!device) {
1279
- res.sendStatus(500);
1262
+ if (existingUser) {
1263
+ const existingBySignKey =
1264
+ await this.db.retrieveDevice(
1265
+ normalizedPayload.signKey,
1266
+ );
1267
+ if (existingBySignKey) {
1268
+ res.status(400).send({
1269
+ error: "Public key is already registered.",
1270
+ });
1271
+ return;
1272
+ }
1273
+ let requesterPasskeyID: string | undefined;
1274
+ if (req.passkey?.passkeyID) {
1275
+ const passkey =
1276
+ await this.db.retrievePasskeyInternal(
1277
+ req.passkey.passkeyID,
1278
+ );
1279
+ if (
1280
+ !passkey ||
1281
+ passkey.userID !== existingUser.userID
1282
+ ) {
1283
+ res.status(403).send({
1284
+ error: "Passkey verification does not match this account.",
1285
+ });
1286
+ return;
1287
+ }
1288
+ requesterPasskeyID = req.passkey.passkeyID;
1289
+ } else {
1290
+ if (
1291
+ typeof normalizedPayload.password !==
1292
+ "string" ||
1293
+ normalizedPayload.password.trim().length ===
1294
+ 0
1295
+ ) {
1296
+ res.status(401).send({
1297
+ error: "Password is required to add this device.",
1298
+ });
1299
+ return;
1300
+ }
1301
+ const { needsRehash, valid } =
1302
+ await verifyPassword(
1303
+ normalizedPayload.password,
1304
+ existingUser,
1305
+ );
1306
+ if (!valid) {
1307
+ res.sendStatus(401);
1308
+ return;
1309
+ }
1310
+ if (needsRehash) {
1311
+ const newHash = await hashPasswordArgon2(
1312
+ normalizedPayload.password,
1313
+ );
1314
+ await this.db.rehashPassword(
1315
+ existingUser.userID,
1316
+ newHash,
1317
+ );
1318
+ }
1319
+ }
1320
+ const activeDevices =
1321
+ await this.db.retrieveUserDeviceList([
1322
+ existingUser.userID,
1323
+ ]);
1324
+ if (
1325
+ activeDevices.length >=
1326
+ MAX_ACTIVE_DEVICES_PER_USER
1327
+ ) {
1328
+ res.status(409).send({
1329
+ error: `Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices. Remove an old device before adding another.`,
1330
+ });
1331
+ return;
1332
+ }
1333
+ const pendingResponse =
1334
+ createPendingDeviceEnrollmentRequest(
1335
+ existingUser.userID,
1336
+ normalizedPayload,
1337
+ this.notify.bind(this),
1338
+ {
1339
+ deferOwnerNotification: true,
1340
+ ...(requesterPasskeyID
1341
+ ? { requesterPasskeyID }
1342
+ : {}),
1343
+ },
1344
+ );
1345
+ res.status(202).send(
1346
+ msgpack.encode(pendingResponse),
1347
+ );
1348
+ return;
1349
+ }
1350
+ if (
1351
+ typeof normalizedPayload.password !== "string" ||
1352
+ normalizedPayload.password.trim().length === 0
1353
+ ) {
1354
+ res.status(400).send({
1355
+ error: "Password is required to register a new account.",
1356
+ });
1280
1357
  return;
1281
1358
  }
1282
- const censored = censorUser(user);
1283
- const token = jwt.sign(
1284
- { user: censored },
1285
- getJwtSecret(),
1286
- { expiresIn: JWT_EXPIRY },
1359
+ const passwordError = validateAccountPassword(
1360
+ normalizedPayload.password,
1361
+ normalizedPayload.username,
1287
1362
  );
1288
- jwt.verify(token, getJwtSecret());
1289
- res.send(
1290
- msgpack.encode({
1291
- device,
1292
- token,
1293
- user: censored,
1294
- }),
1363
+ if (passwordError) {
1364
+ res.status(400).send({
1365
+ error: passwordError,
1366
+ });
1367
+ return;
1368
+ }
1369
+ const [user, err] = await this.db.createUser(
1370
+ regKey,
1371
+ normalizedPayload,
1295
1372
  );
1373
+ if (err !== null) {
1374
+ const errCode =
1375
+ "code" in err && typeof err.code === "string"
1376
+ ? err.code
1377
+ : undefined;
1378
+ const errText = String(err);
1379
+ const usernameConflict =
1380
+ errText.includes("users_username_unique") ||
1381
+ errText.includes("users.username");
1382
+ const signKeyConflict =
1383
+ errText.includes("devices_signKey_unique") ||
1384
+ errText.includes("devices.signKey");
1385
+ const isUniqueConstraint =
1386
+ errCode === "ER_DUP_ENTRY" ||
1387
+ errCode === "SQLITE_CONSTRAINT_UNIQUE" ||
1388
+ errText.includes("UNIQUE constraint failed");
1389
+ if (isUniqueConstraint && usernameConflict) {
1390
+ res.status(400).send({
1391
+ error: "Username is already registered.",
1392
+ });
1393
+ return;
1394
+ }
1395
+ if (isUniqueConstraint && signKeyConflict) {
1396
+ res.status(400).send({
1397
+ error: "Public key is already registered.",
1398
+ });
1399
+ return;
1400
+ }
1401
+ res.sendStatus(500);
1402
+ } else {
1403
+ if (!user) {
1404
+ res.sendStatus(500);
1405
+ return;
1406
+ }
1407
+ const device = await this.db.retrieveDevice(
1408
+ normalizedPayload.signKey,
1409
+ );
1410
+ if (!device) {
1411
+ res.sendStatus(500);
1412
+ return;
1413
+ }
1414
+ const censored = censorUser(user);
1415
+ const token = signAuthJwt(
1416
+ { scope: "user", user: censored },
1417
+ JWT_EXPIRY,
1418
+ );
1419
+ res.send(
1420
+ msgpack.encode({
1421
+ device,
1422
+ token,
1423
+ user: censored,
1424
+ }),
1425
+ );
1426
+ }
1427
+ } else if (regKey && regKey.length !== 16) {
1428
+ res.status(400).send({
1429
+ error: "Invalid registration token payload.",
1430
+ });
1431
+ } else {
1432
+ res.status(400).send({
1433
+ error: "Invalid or no token supplied.",
1434
+ });
1296
1435
  }
1297
- } else if (regKey && regKey.length !== 16) {
1298
- res.status(400).send({
1299
- error: "Invalid registration token payload.",
1300
- });
1301
- } else {
1302
- res.status(400).send({
1303
- error: "Invalid or no token supplied.",
1436
+ } catch (err: unknown) {
1437
+ const requestId = crypto.randomUUID();
1438
+ const message =
1439
+ err instanceof Error ? err.message : String(err);
1440
+ console.error(
1441
+ `[spire] /register failed requestId=${requestId} profile=${this.cryptoProfile} message=${message}`,
1442
+ );
1443
+ if (err instanceof Error && err.stack) {
1444
+ console.error(err.stack);
1445
+ }
1446
+ res.status(500).json({
1447
+ error: `Registration failed. requestId=${requestId}`,
1304
1448
  });
1305
1449
  }
1306
- } catch (err: unknown) {
1307
- const requestId = crypto.randomUUID();
1308
- const message =
1309
- err instanceof Error ? err.message : String(err);
1310
- console.error(
1311
- `[spire] /register failed requestId=${requestId} profile=${this.cryptoProfile} message=${message}`,
1312
- );
1313
- if (err instanceof Error && err.stack) {
1314
- console.error(err.stack);
1315
- }
1316
- res.status(500).json({
1317
- error: `Registration failed. requestId=${requestId}`,
1318
- });
1319
- }
1320
- });
1450
+ },
1451
+ );
1321
1452
 
1322
1453
  this.server = this.api.listen(apiPort);
1323
1454
 
@@ -1349,6 +1480,18 @@ export class Spire extends EventEmitter {
1349
1480
  });
1350
1481
  }
1351
1482
 
1483
+ private pruneActionTokens(now = Date.now()): void {
1484
+ for (const [key, token] of this.actionTokens) {
1485
+ const createdAt = new Date(token.time).getTime();
1486
+ if (
1487
+ !Number.isFinite(createdAt) ||
1488
+ now - createdAt >= TOKEN_EXPIRY
1489
+ ) {
1490
+ this.actionTokens.delete(key);
1491
+ }
1492
+ }
1493
+ }
1494
+
1352
1495
  private removeClient(client: ClientManager): void {
1353
1496
  const idx = this.clients.indexOf(client);
1354
1497
  if (idx !== -1) {
@@ -1357,20 +1500,13 @@ export class Spire extends EventEmitter {
1357
1500
  }
1358
1501
 
1359
1502
  private validateToken(key: string, scope: TokenScopes): boolean {
1360
- for (const rKey of this.actionTokens) {
1361
- if (rKey.key === key) {
1362
- if (rKey.scope !== scope) {
1363
- continue;
1364
- }
1365
-
1366
- const age = Date.now() - new Date(rKey.time).getTime();
1367
- if (age < TOKEN_EXPIRY) {
1368
- this.deleteActionToken(rKey);
1369
- return true;
1370
- }
1371
- }
1503
+ this.pruneActionTokens();
1504
+ const token = this.actionTokens.get(key);
1505
+ if (!token || token.scope !== scope) {
1506
+ return false;
1372
1507
  }
1373
- return false;
1508
+ this.deleteActionToken(token);
1509
+ return true;
1374
1510
  }
1375
1511
  }
1376
1512
 
@@ -1378,19 +1514,7 @@ export class Spire extends EventEmitter {
1378
1514
  // `user` must resolve to the same account. We canonicalize to
1379
1515
  // lowercase at the registration boundary so the persisted row, the
1380
1516
  // UNIQUE index, and every downstream lookup all agree on a single
1381
- // representation. Lookups in `Database.retrieveUser` also fold case
1382
- // at compare time so legacy mixed-case rows still resolve.
1383
- function normalizeRegistrationUsername(
1384
- username: string | undefined,
1385
- signKeyHex: string,
1386
- ): string {
1387
- const trimmed = username?.trim().toLowerCase();
1388
- if (trimmed && trimmed.length > 0) {
1389
- return trimmed;
1390
- }
1391
- const seed = signKeyHex
1392
- .toLowerCase()
1393
- .replace(/[^a-f0-9]/g, "")
1394
- .slice(0, 12);
1395
- return `key_${seed}`;
1517
+ // representation. `Database.retrieveUser` applies the same normalization.
1518
+ function normalizeRegistrationUsername(username: string): string {
1519
+ return username.trim().toLowerCase();
1396
1520
  }