@vex-chat/spire 3.0.1 → 4.1.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 (90) hide show
  1. package/README.md +9 -8
  2. package/dist/Database.d.ts +20 -11
  3. package/dist/Database.js +229 -118
  4. package/dist/Database.js.map +1 -1
  5. package/dist/Spire.d.ts +4 -2
  6. package/dist/Spire.js +155 -72
  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 +33 -6
  15. package/dist/server/avatar.js.map +1 -1
  16. package/dist/server/cliPasskeyPage.js +109 -11
  17. package/dist/server/cliPasskeyPage.js.map +1 -1
  18. package/dist/server/errors.js +8 -0
  19. package/dist/server/errors.js.map +1 -1
  20. package/dist/server/file.js +35 -12
  21. package/dist/server/file.js.map +1 -1
  22. package/dist/server/index.d.ts +8 -0
  23. package/dist/server/index.js +319 -56
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/passkey.js +367 -29
  26. package/dist/server/passkey.js.map +1 -1
  27. package/dist/server/passkeyDevices.js +1 -0
  28. package/dist/server/passkeyDevices.js.map +1 -1
  29. package/dist/server/password.d.ts +7 -0
  30. package/dist/server/password.js +58 -0
  31. package/dist/server/password.js.map +1 -0
  32. package/dist/server/permissions.d.ts +1 -0
  33. package/dist/server/permissions.js +11 -2
  34. package/dist/server/permissions.js.map +1 -1
  35. package/dist/server/rateLimit.d.ts +11 -0
  36. package/dist/server/rateLimit.js +43 -4
  37. package/dist/server/rateLimit.js.map +1 -1
  38. package/dist/server/serverIcon.d.ts +10 -0
  39. package/dist/server/serverIcon.js +158 -0
  40. package/dist/server/serverIcon.js.map +1 -0
  41. package/dist/server/user.d.ts +1 -1
  42. package/dist/server/user.js +40 -9
  43. package/dist/server/user.js.map +1 -1
  44. package/dist/types/express.d.ts +3 -4
  45. package/dist/types/express.js.map +1 -1
  46. package/dist/utils/authJwt.d.ts +8 -0
  47. package/dist/utils/authJwt.js +25 -0
  48. package/dist/utils/authJwt.js.map +1 -0
  49. package/dist/utils/loadEnv.js +4 -0
  50. package/dist/utils/loadEnv.js.map +1 -1
  51. package/dist/utils/preKeySignature.d.ts +1 -1
  52. package/dist/utils/preKeySignature.js +7 -11
  53. package/dist/utils/preKeySignature.js.map +1 -1
  54. package/package.json +7 -7
  55. package/src/Database.ts +294 -152
  56. package/src/Spire.ts +412 -285
  57. package/src/__tests__/Database.spec.ts +126 -3
  58. package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
  59. package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
  60. package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
  61. package/src/__tests__/connectAuth.spec.ts +146 -4
  62. package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
  63. package/src/__tests__/passkeyDevices.spec.ts +94 -0
  64. package/src/__tests__/passkeys.spec.ts +7 -2
  65. package/src/__tests__/password.spec.ts +223 -0
  66. package/src/__tests__/permissions.spec.ts +50 -0
  67. package/src/__tests__/preKeySignature.spec.ts +20 -3
  68. package/src/__tests__/serverManagement.spec.ts +262 -0
  69. package/src/db/schema.ts +0 -2
  70. package/src/migrations/2026-04-06_initial-schema.ts +4 -5
  71. package/src/migrations/2026-07-14_query-indexes.ts +34 -0
  72. package/src/server/avatar.ts +32 -6
  73. package/src/server/cliPasskeyPage.ts +109 -11
  74. package/src/server/errors.ts +7 -0
  75. package/src/server/file.ts +34 -13
  76. package/src/server/index.ts +494 -139
  77. package/src/server/passkey.ts +531 -31
  78. package/src/server/passkeyDevices.ts +1 -0
  79. package/src/server/password.ts +96 -0
  80. package/src/server/permissions.ts +20 -2
  81. package/src/server/rateLimit.ts +47 -4
  82. package/src/server/serverIcon.ts +199 -0
  83. package/src/server/user.ts +44 -9
  84. package/src/types/express.ts +3 -4
  85. package/src/utils/authJwt.ts +34 -0
  86. package/src/utils/loadEnv.ts +4 -0
  87. package/src/utils/preKeySignature.ts +12 -15
  88. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
  89. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
  90. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
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,18 +59,23 @@ import {
53
59
  MailIngressValidationError,
54
60
  validateMailIngress,
55
61
  } from "./server/mailIngress.ts";
56
- import { authLimiter, devApiKeySkipsRateLimits } from "./server/rateLimit.ts";
62
+ import {
63
+ accountAuthLimiter,
64
+ authLimiter,
65
+ devApiKeySkipsRateLimits,
66
+ } from "./server/rateLimit.ts";
57
67
  import { createPendingDeviceEnrollmentRequest } from "./server/user.ts";
58
68
  import { censorUser, getParam, getUser } from "./server/utils.ts";
59
69
  import { resolveSpireListenPort } from "./spireListenPort.ts";
60
- import { getJwtSecret } from "./utils/jwtSecret.ts";
70
+ import { signAuthJwt, verifyAuthJwt } from "./utils/authJwt.ts";
61
71
  import { msgpack } from "./utils/msgpack.ts";
72
+ import { verifyDevicePayloadPreKeySignature } from "./utils/preKeySignature.ts";
62
73
  import { spireXSignOpenAsync } from "./utils/spireXSignOpenAsync.ts";
63
74
 
64
- // expiry of regkeys = 24hr
75
+ // One-use action tokens expire after ten minutes.
65
76
  export const TOKEN_EXPIRY = 1000 * 60 * 10;
66
- export const JWT_EXPIRY = "7d";
67
- export const DEVICE_AUTH_JWT_EXPIRY = "7d";
77
+ export const JWT_EXPIRY = "1h";
78
+ export const DEVICE_AUTH_JWT_EXPIRY = "1h";
68
79
  /**
69
80
  * Passkey-scoped JWTs grant destructive admin powers (delete a
70
81
  * device, recover a device enrollment) without further user
@@ -76,6 +87,8 @@ const DEVICE_CHALLENGE_EXPIRY = 1000 * 60; // 60 seconds
76
87
 
77
88
  // 3-19 chars long
78
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;
79
92
 
80
93
  // ── Zod schemas for trust-boundary validation ──────────────────────────
81
94
  const wsAuthMsg = z.object({
@@ -86,26 +99,65 @@ const wsAuthMsg = z.object({
86
99
  const jwtPayload = z.object({
87
100
  bearerToken: z.string().optional(),
88
101
  exp: z.number().optional(),
102
+ scope: z.literal("user"),
89
103
  user: UserSchema,
90
104
  });
91
105
 
92
106
  const authPayload = z.object({
93
- password: z.string().min(1),
94
- 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(),
95
137
  });
96
138
 
97
139
  const deviceAuthPayload = z.object({
98
- deviceID: z.string().min(1),
99
- 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})+$/),
100
146
  });
101
147
 
102
148
  const deviceVerifyPayload = z.object({
103
- challengeID: z.string().min(1),
104
- 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})+$/),
105
155
  });
106
156
 
107
157
  const mailPostPayload = z.object({
108
- header: z.custom<Uint8Array>((val) => val instanceof Uint8Array),
158
+ header: z.custom<Uint8Array>(
159
+ (val) => val instanceof Uint8Array && val.byteLength === 32,
160
+ ),
109
161
  mail: MailWSSchema,
110
162
  });
111
163
  const MAIL_BATCH_MAX_ITEMS = 256;
@@ -131,12 +183,12 @@ interface ValidatedMailBatchEntry {
131
183
 
132
184
  const notificationSubscribePayload = z.object({
133
185
  channel: z.literal("expo"),
134
- events: z.array(z.string().min(1)).default(["mail"]),
186
+ events: z.array(z.string().min(1).max(64)).max(32).default(["mail"]),
135
187
  platform: z.enum(["android", "ios", "web"]).optional(),
136
- token: z.string().min(1),
188
+ token: z.string().min(1).max(4096),
137
189
  });
138
190
 
139
- const directories = ["files", "avatars", "emoji"];
191
+ const directories = ["files", "avatars", "emoji", "server-icons"];
140
192
  for (const dir of directories) {
141
193
  if (!fs.existsSync(dir)) {
142
194
  fs.mkdirSync(dir);
@@ -194,6 +246,19 @@ export interface SpireOptions {
194
246
  dbType?: "mysql" | "sqlite3" | "sqlite3mem" | "sqlite";
195
247
  }
196
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
+
197
262
  /**
198
263
  * Masks identifying material in the access-log URL: hyphenated UUIDs, and
199
264
  * `/device/...` path segments that hold public-key material (32B ed25519 hex, or
@@ -213,7 +278,7 @@ function redactAccessLogUrl(url: string): string {
213
278
  let pendingFipsKeyPair: KeyPair | null = null;
214
279
 
215
280
  export class Spire extends EventEmitter {
216
- private actionTokens: ActionToken[] = [];
281
+ private actionTokens = new Map<string, ActionToken>();
217
282
  private api = express();
218
283
  private calls: CallManager;
219
284
  private clients: ClientManager[] = [];
@@ -258,12 +323,13 @@ export class Spire extends EventEmitter {
258
323
  this.signKeys = xSignKeyPairFromSecret(XUtils.decodeHex(SK));
259
324
  }
260
325
 
261
- // Trust a single proxy hop (nginx / cloudflare / load balancer).
262
- // Required so `req.ip` and `express-rate-limit`'s keyGenerator see
263
- // the real client address via X-Forwarded-For. Never use `true`
264
- // that lets attackers spoof the header and bypass rate limiting.
265
- // If spire is deployed without a proxy, set this to 0 instead.
266
- 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
+ );
267
333
  this.api.disable("etag");
268
334
 
269
335
  this.db = new Database(options);
@@ -359,20 +425,18 @@ export class Spire extends EventEmitter {
359
425
  }
360
426
 
361
427
  private createActionToken(scope: TokenScopes): ActionToken {
428
+ this.pruneActionTokens();
362
429
  const token: ActionToken = {
363
430
  key: crypto.randomUUID(),
364
431
  scope,
365
432
  time: new Date().toISOString(),
366
433
  };
367
- this.actionTokens.push(token);
434
+ this.actionTokens.set(token.key, token);
368
435
  return token;
369
436
  }
370
437
 
371
438
  private deleteActionToken(key: ActionToken) {
372
- const idx = this.actionTokens.indexOf(key);
373
- if (idx !== -1) {
374
- this.actionTokens.splice(idx, 1);
375
- }
439
+ this.actionTokens.delete(key.key);
376
440
  }
377
441
 
378
442
  private disconnectDevices(deviceIDs: string[]): void {
@@ -454,10 +518,7 @@ export class Spire extends EventEmitter {
454
518
  JSON.stringify(authResult.error.issues),
455
519
  );
456
520
  }
457
- const result = jwt.verify(
458
- authResult.data.token,
459
- getJwtSecret(),
460
- );
521
+ const result = verifyAuthJwt(authResult.data.token);
461
522
  const jwtResult = jwtPayload.safeParse(result);
462
523
  if (!jwtResult.success) {
463
524
  throw new Error(
@@ -689,9 +750,10 @@ export class Spire extends EventEmitter {
689
750
  }
690
751
  });
691
752
 
692
- this.api.post("/goodbye", protect, (req, res) => {
693
- jwt.sign({ user: req.user }, getJwtSecret(), { expiresIn: -1 });
694
- 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);
695
757
  });
696
758
 
697
759
  // ── Device-key auth ──────────────────────────────────────────
@@ -794,11 +856,10 @@ export class Spire extends EventEmitter {
794
856
  .send({ error: "Device owner not found." });
795
857
  }
796
858
 
797
- // Issue short-lived JWT (1 hour, not 7 days)
798
- const token = jwt.sign(
799
- { user: censorUser(user) },
800
- getJwtSecret(),
801
- { 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,
802
863
  );
803
864
  return res.send(
804
865
  msgpack.encode({ token, user: censorUser(user) }),
@@ -1058,263 +1119,336 @@ export class Spire extends EventEmitter {
1058
1119
  },
1059
1120
  );
1060
1121
 
1061
- this.api.post("/auth", authLimiter, async (req, res) => {
1062
- const parsed = authPayload.safeParse(req.body);
1063
- if (!parsed.success) {
1064
- res.status(400).json({
1065
- error: "Invalid credentials format",
1066
- });
1067
- return;
1068
- }
1069
- const { password, username } = parsed.data;
1070
-
1071
- try {
1072
- const userEntry = await this.db.retrieveUser(username);
1073
- if (!userEntry) {
1074
- 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
+ });
1075
1132
  return;
1076
1133
  }
1134
+ const { password } = parsed.data;
1135
+ const username = parsed.data.username.toLowerCase();
1077
1136
 
1078
- const { needsRehash, valid } = await verifyPassword(
1079
- password,
1080
- userEntry,
1081
- );
1082
-
1083
- if (!valid) {
1084
- res.sendStatus(401);
1085
- return;
1086
- }
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
+ }
1087
1144
 
1088
- if (needsRehash) {
1089
- const newHash = await hashPasswordArgon2(password);
1090
- await this.db.rehashPassword(userEntry.userID, newHash);
1091
- }
1145
+ const { needsRehash, valid } = await verifyPassword(
1146
+ password,
1147
+ userEntry,
1148
+ );
1092
1149
 
1093
- const token = jwt.sign(
1094
- { user: censorUser(userEntry) },
1095
- getJwtSecret(),
1096
- { expiresIn: JWT_EXPIRY },
1097
- );
1150
+ if (!valid) {
1151
+ res.sendStatus(401);
1152
+ return;
1153
+ }
1098
1154
 
1099
- // just to make sure
1100
- jwt.verify(token, getJwtSecret());
1155
+ if (needsRehash) {
1156
+ const newHash = await hashPasswordArgon2(password);
1157
+ await this.db.rehashPassword(userEntry.userID, newHash);
1158
+ }
1101
1159
 
1102
- res.send(
1103
- msgpack.encode({ token, user: censorUser(userEntry) }),
1104
- );
1105
- } catch (_err: unknown) {
1106
- // debugger: auth error
1107
- res.sendStatus(500);
1108
- }
1109
- });
1160
+ const token = signAuthJwt(
1161
+ { scope: "user", user: censorUser(userEntry) },
1162
+ JWT_EXPIRY,
1163
+ );
1110
1164
 
1111
- this.api.post("/register", authLimiter, async (req, res) => {
1112
- try {
1113
- const regParsed = RegistrationPayloadSchema.safeParse(req.body);
1114
- if (!regParsed.success) {
1115
- res.status(400).json({
1116
- error: "Invalid registration payload",
1117
- issues: regParsed.error.issues,
1118
- });
1119
- return;
1120
- }
1121
- const regPayload = regParsed.data;
1122
- if (
1123
- regPayload.username !== undefined &&
1124
- !usernameRegex.test(regPayload.username)
1125
- ) {
1126
- res.status(400).send({
1127
- error: "Username must be between three and nineteen letters, digits, or underscores.",
1128
- });
1129
- return;
1165
+ res.send(
1166
+ msgpack.encode({ token, user: censorUser(userEntry) }),
1167
+ );
1168
+ } catch (_err: unknown) {
1169
+ // debugger: auth error
1170
+ res.sendStatus(500);
1130
1171
  }
1131
- const normalizedPayload = {
1132
- ...regPayload,
1133
- username: normalizeRegistrationUsername(
1134
- regPayload.username,
1135
- regPayload.signKey,
1136
- ),
1137
- };
1138
-
1139
- const regKey = await spireXSignOpenAsync(
1140
- XUtils.decodeHex(normalizedPayload.signed),
1141
- XUtils.decodeHex(normalizedPayload.signKey),
1142
- );
1172
+ },
1173
+ );
1174
+
1175
+ this.api.post(
1176
+ "/register",
1177
+ authLimiter,
1178
+ accountAuthLimiter,
1179
+ async (req, res) => {
1180
+ try {
1181
+ const regParsed = RegistrationPayloadSchema.safeParse(
1182
+ req.body,
1183
+ );
1184
+ if (!regParsed.success) {
1185
+ res.status(400).json({
1186
+ error: "Invalid registration payload",
1187
+ issues: regParsed.error.issues,
1188
+ });
1189
+ return;
1190
+ }
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)) {
1202
+ res.status(400).send({
1203
+ error: "Username must be between three and nineteen letters, digits, or underscores.",
1204
+ });
1205
+ return;
1206
+ }
1207
+ const normalizedPayload = {
1208
+ ...regPayload,
1209
+ username: normalizeRegistrationUsername(
1210
+ regPayload.username,
1211
+ ),
1212
+ };
1143
1213
 
1144
- if (
1145
- regKey &&
1146
- regKey.length === 16 &&
1147
- this.validateToken(
1148
- uuidStringify(regKey),
1149
- TokenScopes.Register,
1150
- )
1151
- ) {
1152
- const existingUser = await this.db.retrieveUser(
1153
- normalizedPayload.username,
1214
+ const regKey = await spireXSignOpenAsync(
1215
+ XUtils.decodeHex(normalizedPayload.signed),
1216
+ XUtils.decodeHex(normalizedPayload.signKey),
1154
1217
  );
1155
- if (existingUser) {
1156
- const existingBySignKey = await this.db.retrieveDevice(
1157
- normalizedPayload.signKey,
1158
- );
1159
- if (existingBySignKey) {
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
+ ) {
1160
1232
  res.status(400).send({
1161
- error: "Public key is already registered.",
1233
+ error: "Signed prekey signature is invalid.",
1234
+ });
1235
+ return;
1236
+ }
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.",
1162
1246
  });
1163
1247
  return;
1164
1248
  }
1165
- let requesterPasskeyID: string | undefined;
1166
- if (req.passkey?.passkeyID) {
1167
- const passkey =
1168
- await this.db.retrievePasskeyInternal(
1169
- req.passkey.passkeyID,
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
+ });
1260
+ return;
1261
+ }
1262
+ if (existingUser) {
1263
+ const existingBySignKey =
1264
+ await this.db.retrieveDevice(
1265
+ normalizedPayload.signKey,
1170
1266
  );
1171
- if (
1172
- !passkey ||
1173
- passkey.userID !== existingUser.userID
1174
- ) {
1175
- res.status(403).send({
1176
- error: "Passkey verification does not match this account.",
1267
+ if (existingBySignKey) {
1268
+ res.status(400).send({
1269
+ error: "Public key is already registered.",
1177
1270
  });
1178
1271
  return;
1179
1272
  }
1180
- requesterPasskeyID = req.passkey.passkeyID;
1181
- } else {
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
+ ]);
1182
1324
  if (
1183
- typeof normalizedPayload.password !==
1184
- "string" ||
1185
- normalizedPayload.password.trim().length === 0
1325
+ activeDevices.length >=
1326
+ MAX_ACTIVE_DEVICES_PER_USER
1186
1327
  ) {
1187
- res.status(401).send({
1188
- error: "Password is required to add this device.",
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.`,
1189
1330
  });
1190
1331
  return;
1191
1332
  }
1192
- const { needsRehash, valid } = await verifyPassword(
1193
- normalizedPayload.password,
1194
- existingUser,
1195
- );
1196
- if (!valid) {
1197
- res.sendStatus(401);
1198
- return;
1199
- }
1200
- if (needsRehash) {
1201
- const newHash = await hashPasswordArgon2(
1202
- normalizedPayload.password,
1203
- );
1204
- await this.db.rehashPassword(
1333
+ const pendingResponse =
1334
+ createPendingDeviceEnrollmentRequest(
1205
1335
  existingUser.userID,
1206
- newHash,
1336
+ normalizedPayload,
1337
+ this.notify.bind(this),
1338
+ {
1339
+ deferOwnerNotification: true,
1340
+ ...(requesterPasskeyID
1341
+ ? { requesterPasskeyID }
1342
+ : {}),
1343
+ },
1207
1344
  );
1208
- }
1209
- }
1210
- const pendingResponse =
1211
- createPendingDeviceEnrollmentRequest(
1212
- existingUser.userID,
1213
- normalizedPayload,
1214
- this.notify.bind(this),
1215
- {
1216
- deferOwnerNotification: true,
1217
- ...(requesterPasskeyID
1218
- ? { requesterPasskeyID }
1219
- : {}),
1220
- },
1345
+ res.status(202).send(
1346
+ msgpack.encode(pendingResponse),
1221
1347
  );
1222
- res.status(202).send(msgpack.encode(pendingResponse));
1223
- return;
1224
- }
1225
- if (
1226
- typeof normalizedPayload.password !== "string" ||
1227
- normalizedPayload.password.trim().length === 0
1228
- ) {
1229
- res.status(400).send({
1230
- error: "Password is required to register a new account.",
1231
- });
1232
- return;
1233
- }
1234
- const [user, err] = await this.db.createUser(
1235
- regKey,
1236
- normalizedPayload,
1237
- );
1238
- if (err !== null) {
1239
- const errCode =
1240
- "code" in err && typeof err.code === "string"
1241
- ? err.code
1242
- : undefined;
1243
- const errText = String(err);
1244
- const usernameConflict =
1245
- errText.includes("users_username_unique") ||
1246
- errText.includes("users.username");
1247
- const signKeyConflict =
1248
- errText.includes("devices_signKey_unique") ||
1249
- errText.includes("devices.signKey");
1250
- const isUniqueConstraint =
1251
- errCode === "ER_DUP_ENTRY" ||
1252
- errCode === "SQLITE_CONSTRAINT_UNIQUE" ||
1253
- errText.includes("UNIQUE constraint failed");
1254
- if (isUniqueConstraint && usernameConflict) {
1255
- res.status(400).send({
1256
- error: "Username is already registered.",
1257
- });
1258
1348
  return;
1259
1349
  }
1260
- if (isUniqueConstraint && signKeyConflict) {
1350
+ if (
1351
+ typeof normalizedPayload.password !== "string" ||
1352
+ normalizedPayload.password.trim().length === 0
1353
+ ) {
1261
1354
  res.status(400).send({
1262
- error: "Public key is already registered.",
1355
+ error: "Password is required to register a new account.",
1263
1356
  });
1264
1357
  return;
1265
1358
  }
1266
- res.sendStatus(500);
1267
- } else {
1268
- if (!user) {
1269
- res.sendStatus(500);
1359
+ const passwordError = validateAccountPassword(
1360
+ normalizedPayload.password,
1361
+ normalizedPayload.username,
1362
+ );
1363
+ if (passwordError) {
1364
+ res.status(400).send({
1365
+ error: passwordError,
1366
+ });
1270
1367
  return;
1271
1368
  }
1272
- const device = await this.db.retrieveDevice(
1273
- normalizedPayload.signKey,
1369
+ const [user, err] = await this.db.createUser(
1370
+ regKey,
1371
+ normalizedPayload,
1274
1372
  );
1275
- if (!device) {
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
+ }
1276
1401
  res.sendStatus(500);
1277
- return;
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
+ );
1278
1426
  }
1279
- const censored = censorUser(user);
1280
- const token = jwt.sign(
1281
- { user: censored },
1282
- getJwtSecret(),
1283
- { expiresIn: JWT_EXPIRY },
1284
- );
1285
- jwt.verify(token, getJwtSecret());
1286
- res.send(
1287
- msgpack.encode({
1288
- device,
1289
- token,
1290
- user: censored,
1291
- }),
1292
- );
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
+ });
1293
1435
  }
1294
- } else if (regKey && regKey.length !== 16) {
1295
- res.status(400).send({
1296
- error: "Invalid registration token payload.",
1297
- });
1298
- } else {
1299
- res.status(400).send({
1300
- 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}`,
1301
1448
  });
1302
1449
  }
1303
- } catch (err: unknown) {
1304
- const requestId = crypto.randomUUID();
1305
- const message =
1306
- err instanceof Error ? err.message : String(err);
1307
- console.error(
1308
- `[spire] /register failed requestId=${requestId} profile=${this.cryptoProfile} message=${message}`,
1309
- );
1310
- if (err instanceof Error && err.stack) {
1311
- console.error(err.stack);
1312
- }
1313
- res.status(500).json({
1314
- error: `Registration failed. requestId=${requestId}`,
1315
- });
1316
- }
1317
- });
1450
+ },
1451
+ );
1318
1452
 
1319
1453
  this.server = this.api.listen(apiPort);
1320
1454
 
@@ -1346,6 +1480,18 @@ export class Spire extends EventEmitter {
1346
1480
  });
1347
1481
  }
1348
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
+
1349
1495
  private removeClient(client: ClientManager): void {
1350
1496
  const idx = this.clients.indexOf(client);
1351
1497
  if (idx !== -1) {
@@ -1354,20 +1500,13 @@ export class Spire extends EventEmitter {
1354
1500
  }
1355
1501
 
1356
1502
  private validateToken(key: string, scope: TokenScopes): boolean {
1357
- for (const rKey of this.actionTokens) {
1358
- if (rKey.key === key) {
1359
- if (rKey.scope !== scope) {
1360
- continue;
1361
- }
1362
-
1363
- const age = Date.now() - new Date(rKey.time).getTime();
1364
- if (age < TOKEN_EXPIRY) {
1365
- this.deleteActionToken(rKey);
1366
- return true;
1367
- }
1368
- }
1503
+ this.pruneActionTokens();
1504
+ const token = this.actionTokens.get(key);
1505
+ if (!token || token.scope !== scope) {
1506
+ return false;
1369
1507
  }
1370
- return false;
1508
+ this.deleteActionToken(token);
1509
+ return true;
1371
1510
  }
1372
1511
  }
1373
1512
 
@@ -1375,19 +1514,7 @@ export class Spire extends EventEmitter {
1375
1514
  // `user` must resolve to the same account. We canonicalize to
1376
1515
  // lowercase at the registration boundary so the persisted row, the
1377
1516
  // UNIQUE index, and every downstream lookup all agree on a single
1378
- // representation. Lookups in `Database.retrieveUser` also fold case
1379
- // at compare time so legacy mixed-case rows still resolve.
1380
- function normalizeRegistrationUsername(
1381
- username: string | undefined,
1382
- signKeyHex: string,
1383
- ): string {
1384
- const trimmed = username?.trim().toLowerCase();
1385
- if (trimmed && trimmed.length > 0) {
1386
- return trimmed;
1387
- }
1388
- const seed = signKeyHex
1389
- .toLowerCase()
1390
- .replace(/[^a-f0-9]/g, "")
1391
- .slice(0, 12);
1392
- return `key_${seed}`;
1517
+ // representation. `Database.retrieveUser` applies the same normalization.
1518
+ function normalizeRegistrationUsername(username: string): string {
1519
+ return username.trim().toLowerCase();
1393
1520
  }