@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/Database.ts CHANGED
@@ -32,10 +32,10 @@ import type {
32
32
  Server,
33
33
  UserRecord,
34
34
  } from "@vex-chat/types";
35
- import type { Migration, MigrationProvider } from "kysely";
35
+ import type { Migration, MigrationProvider, Transaction } from "kysely";
36
36
 
37
37
  import { EventEmitter } from "events";
38
- import { createHash, pbkdf2Sync } from "node:crypto";
38
+ import { createHash } from "node:crypto";
39
39
  import { statSync } from "node:fs";
40
40
  import * as fs from "node:fs/promises";
41
41
  import path from "node:path";
@@ -47,6 +47,8 @@ import {
47
47
  XUtils,
48
48
  } from "@vex-chat/crypto";
49
49
  import {
50
+ ACCOUNT_PASSWORD_MAX_LENGTH,
51
+ ACCOUNT_PASSWORD_MIN_LENGTH,
50
52
  AccountEntitlementSourceSchema,
51
53
  AccountTierSchema,
52
54
  BillingEnvironmentSchema,
@@ -100,6 +102,8 @@ import BetterSqlite3 from "better-sqlite3";
100
102
  import { Kysely, Migrator, sql, SqliteDialect } from "kysely";
101
103
  import { stringify as uuidStringify, validate as uuidValidate } from "uuid";
102
104
 
105
+ export const MAX_ACTIVE_DEVICES_PER_USER = 20;
106
+
103
107
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
104
108
  const migrationFolder = path.join(__dirname, "migrations");
105
109
 
@@ -155,20 +159,31 @@ function isMigration(mod: unknown): mod is Migration {
155
159
  );
156
160
  }
157
161
 
158
- const pubkeyRegex = /[0-9a-f]{64}/;
159
-
160
- /** Legacy iteration count kept only for verifying old PBKDF2 hashes. */
161
- const PBKDF2_ITERATIONS = 1000;
162
+ const pubkeyRegex = /^(?:[0-9a-fA-F]{2}){32,4096}$/;
163
+ const DUMMY_PASSWORD_HASH =
164
+ "$argon2id$v=19$m=65536,t=3,p=1$SZlCYiWwt450ZWt2zRvDIw$QZdY/EtG81hEAXYRLVDvqtpJbajXL1/QRM91ZT9DQPk";
165
+ const ARGON2_OPTIONS = {
166
+ memoryCost: 65_536,
167
+ parallelism: 1,
168
+ timeCost: 3,
169
+ type: argon2.argon2id,
170
+ } as const;
171
+
172
+ const COMMON_ACCOUNT_PASSWORDS = new Set([
173
+ "123456789012345",
174
+ "adminadminadminadmin",
175
+ "letmeinletmeinletmein",
176
+ "passwordpassword",
177
+ "passwordpasswordpassword",
178
+ "qwertyuiopasdfgh",
179
+ ]);
180
+
181
+ export type InternalUserRecord = UserRecord;
162
182
 
163
183
  // ── Row-to-interface converters ─────────────────────────────────────────
164
184
  // SQLite stores booleans as integers and dates as strings, but the
165
185
  // @vex-chat/types interfaces expect boolean / Date.
166
186
 
167
- /** Internal record that includes the hash algorithm discriminator. */
168
- export interface InternalUserRecord extends UserRecord {
169
- hashAlgo: string;
170
- }
171
-
172
187
  export interface NotificationSubscription {
173
188
  channel: "expo";
174
189
  createdAt: string;
@@ -272,55 +287,11 @@ export class Database extends EventEmitter {
272
287
  payload: DevicePayload,
273
288
  passkeyApproval?: DevicePasskeyApprovalInput,
274
289
  ): Promise<Device> {
275
- const now = new Date().toISOString();
276
- const device = {
277
- deleted: 0,
278
- deviceID: crypto.randomUUID(),
279
- lastLogin: now,
280
- name: payload.deviceName,
281
- owner,
282
- signKey: payload.signKey,
283
- };
284
-
285
- const medPreKeys = {
286
- deviceID: device.deviceID,
287
- index: payload.preKeyIndex,
288
- keyID: crypto.randomUUID(),
289
- publicKey: payload.preKey,
290
- signature: payload.preKeySignature,
291
- userID: owner,
292
- };
293
-
294
- await this.db.transaction().execute(async (trx) => {
295
- await trx.insertInto("devices").values(device).execute();
296
- await trx.insertInto("preKeys").values(medPreKeys).execute();
297
- if (passkeyApproval) {
298
- await trx
299
- .insertInto("device_passkey_approvals")
300
- .values({
301
- approvedAt: now,
302
- approvedByDeviceID:
303
- passkeyApproval.approvedByDeviceID ?? null,
304
- approvedByPasskeyID:
305
- passkeyApproval.approvedByPasskeyID,
306
- deviceID: device.deviceID,
307
- userID: owner,
308
- })
309
- .onConflict((oc) =>
310
- oc.column("deviceID").doUpdateSet({
311
- approvedAt: now,
312
- approvedByDeviceID:
313
- passkeyApproval.approvedByDeviceID ?? null,
314
- approvedByPasskeyID:
315
- passkeyApproval.approvedByPasskeyID,
316
- userID: owner,
317
- }),
318
- )
319
- .execute();
320
- }
321
- });
322
-
323
- return toDevice(device);
290
+ return this.db
291
+ .transaction()
292
+ .execute(async (trx) =>
293
+ this.insertDevice(trx, owner, payload, passkeyApproval),
294
+ );
324
295
  }
325
296
 
326
297
  public async createEmoji(emoji: Emoji): Promise<void> {
@@ -453,10 +424,7 @@ export class Database extends EventEmitter {
453
424
  ): Promise<[null | UserRecord, Error | null]> {
454
425
  try {
455
426
  const userID = uuidStringify(regKey);
456
- const username = normalizeRegistrationUsername(
457
- regPayload.username,
458
- userID,
459
- );
427
+ const username = normalizeRegistrationUsername(regPayload.username);
460
428
  if (
461
429
  typeof regPayload.password !== "string" ||
462
430
  regPayload.password.trim().length === 0
@@ -465,25 +433,32 @@ export class Database extends EventEmitter {
465
433
  "Password is required to register a new account.",
466
434
  );
467
435
  }
436
+ const passwordError = validateAccountPassword(
437
+ regPayload.password,
438
+ username,
439
+ );
440
+ if (passwordError) {
441
+ throw new Error(passwordError);
442
+ }
468
443
  const passwordHash = await hashPasswordArgon2(regPayload.password);
469
444
 
470
445
  const user: UserRecord = {
471
446
  lastSeen: new Date().toISOString(),
472
447
  passwordHash,
473
- passwordSalt: "",
474
448
  userID,
475
449
  username,
476
450
  };
477
451
 
478
- await this.db
479
- .insertInto("users")
480
- .values({
481
- ...user,
482
- hashAlgo: "argon2id",
483
- lastSeen: user.lastSeen,
484
- })
485
- .execute();
486
- await this.createDevice(user.userID, regPayload);
452
+ await this.db.transaction().execute(async (trx) => {
453
+ await trx
454
+ .insertInto("users")
455
+ .values({
456
+ ...user,
457
+ lastSeen: user.lastSeen,
458
+ })
459
+ .execute();
460
+ await this.insertDevice(trx, user.userID, regPayload);
461
+ });
487
462
 
488
463
  return [user, null];
489
464
  } catch (err: unknown) {
@@ -503,32 +478,72 @@ export class Database extends EventEmitter {
503
478
  .execute();
504
479
  }
505
480
 
481
+ /** Delete a channel while preserving the invariant that a server has one. */
482
+ public async deleteChannelIfNotLast(channelID: string): Promise<boolean> {
483
+ return this.db.transaction().execute(async (trx) => {
484
+ const channel = await trx
485
+ .selectFrom("channels")
486
+ .selectAll()
487
+ .where("channelID", "=", channelID)
488
+ .executeTakeFirst();
489
+ if (!channel) {
490
+ return false;
491
+ }
492
+
493
+ const siblings = await trx
494
+ .selectFrom("channels")
495
+ .select("channelID")
496
+ .where("serverID", "=", channel.serverID)
497
+ .limit(2)
498
+ .execute();
499
+ if (siblings.length <= 1) {
500
+ return false;
501
+ }
502
+
503
+ await trx
504
+ .deleteFrom("permissions")
505
+ .where("resourceID", "=", channelID)
506
+ .execute();
507
+ await trx
508
+ .deleteFrom("mail")
509
+ .where("group", "=", channelID)
510
+ .execute();
511
+ await trx
512
+ .deleteFrom("channels")
513
+ .where("channelID", "=", channelID)
514
+ .execute();
515
+ return true;
516
+ });
517
+ }
518
+
506
519
  public async deleteDevice(deviceID: string): Promise<void> {
507
- await this.db
508
- .deleteFrom("preKeys")
509
- .where("deviceID", "=", deviceID)
510
- .execute();
520
+ await this.db.transaction().execute(async (trx) => {
521
+ await trx
522
+ .deleteFrom("preKeys")
523
+ .where("deviceID", "=", deviceID)
524
+ .execute();
511
525
 
512
- await this.db
513
- .deleteFrom("oneTimeKeys")
514
- .where("deviceID", "=", deviceID)
515
- .execute();
526
+ await trx
527
+ .deleteFrom("oneTimeKeys")
528
+ .where("deviceID", "=", deviceID)
529
+ .execute();
516
530
 
517
- await this.db
518
- .deleteFrom("notification_subscriptions")
519
- .where("deviceID", "=", deviceID)
520
- .execute();
531
+ await trx
532
+ .deleteFrom("notification_subscriptions")
533
+ .where("deviceID", "=", deviceID)
534
+ .execute();
521
535
 
522
- await this.db
523
- .deleteFrom("device_passkey_approvals")
524
- .where("deviceID", "=", deviceID)
525
- .execute();
536
+ await trx
537
+ .deleteFrom("device_passkey_approvals")
538
+ .where("deviceID", "=", deviceID)
539
+ .execute();
526
540
 
527
- await this.db
528
- .updateTable("devices")
529
- .set({ deleted: 1 })
530
- .where("deviceID", "=", deviceID)
531
- .execute();
541
+ await trx
542
+ .updateTable("devices")
543
+ .set({ deleted: 1 })
544
+ .where("deviceID", "=", deviceID)
545
+ .execute();
546
+ });
532
547
  }
533
548
 
534
549
  public async deleteEmoji(emojiID: string): Promise<void> {
@@ -815,16 +830,19 @@ export class Database extends EventEmitter {
815
830
  */
816
831
  public async markPasskeyUsed(
817
832
  passkeyID: string,
833
+ expectedSignCount: number,
818
834
  signCount: number,
819
- ): Promise<void> {
820
- await this.db
835
+ ): Promise<boolean> {
836
+ const result = await this.db
821
837
  .updateTable("passkeys")
822
838
  .set({
823
839
  lastUsedAt: new Date().toISOString(),
824
840
  signCount,
825
841
  })
826
842
  .where("passkeyID", "=", passkeyID)
827
- .execute();
843
+ .where("signCount", "=", expectedSignCount)
844
+ .executeTakeFirst();
845
+ return Number(result.numUpdatedRows) > 0;
828
846
  }
829
847
 
830
848
  public async markUserSeen(user: UserRecord): Promise<void> {
@@ -1013,9 +1031,7 @@ export class Database extends EventEmitter {
1013
1031
  await this.db
1014
1032
  .updateTable("users")
1015
1033
  .set({
1016
- hashAlgo: "argon2id",
1017
1034
  passwordHash: newHash,
1018
- passwordSalt: "",
1019
1035
  })
1020
1036
  .where("userID", "=", userID)
1021
1037
  .execute();
@@ -1526,13 +1542,8 @@ export class Database extends EventEmitter {
1526
1542
  return row?.userID ?? null;
1527
1543
  }
1528
1544
 
1529
- // The identifier is matched as either a userID (UUID branch) or a
1530
- // username (string branch). Username comparison is case-folded so
1531
- // `User` and `user` resolve to the same row regardless of how the
1532
- // caller typed it — the canonical form on disk is lowercase
1533
- // (`normalizeRegistrationUsername`), but legacy mixed-case rows
1534
- // from before the canonicalization landed still resolve via the
1535
- // `lower(username) = lower(?)` predicate below.
1545
+ // The identifier is matched as either a userID or the canonical lowercase
1546
+ // username stored by `normalizeRegistrationUsername`.
1536
1547
  public async retrieveUser(
1537
1548
  userIdentifier: string,
1538
1549
  ): Promise<InternalUserRecord | null> {
@@ -1545,11 +1556,11 @@ export class Database extends EventEmitter {
1545
1556
  .limit(1)
1546
1557
  .execute();
1547
1558
  } else {
1548
- const normalized = userIdentifier.toLowerCase();
1559
+ const normalized = userIdentifier.trim().toLowerCase();
1549
1560
  rows = await this.db
1550
1561
  .selectFrom("users")
1551
1562
  .selectAll()
1552
- .where(sql<string>`lower(username)`, "=", normalized)
1563
+ .where("username", "=", normalized)
1553
1564
  .limit(1)
1554
1565
  .execute();
1555
1566
  }
@@ -1731,6 +1742,51 @@ export class Database extends EventEmitter {
1731
1742
  });
1732
1743
  }
1733
1744
 
1745
+ public async updateChannel(
1746
+ channelID: string,
1747
+ name: string,
1748
+ ): Promise<Channel | null> {
1749
+ const result = await this.db
1750
+ .updateTable("channels")
1751
+ .set({ name })
1752
+ .where("channelID", "=", channelID)
1753
+ .executeTakeFirst();
1754
+ if (Number(result.numUpdatedRows) === 0) {
1755
+ return null;
1756
+ }
1757
+ return this.retrieveChannel(channelID);
1758
+ }
1759
+
1760
+ public async updatePermissionPowerLevel(
1761
+ permissionID: string,
1762
+ powerLevel: number,
1763
+ ): Promise<null | Permission> {
1764
+ const result = await this.db
1765
+ .updateTable("permissions")
1766
+ .set({ powerLevel })
1767
+ .where("permissionID", "=", permissionID)
1768
+ .executeTakeFirst();
1769
+ if (Number(result.numUpdatedRows) === 0) {
1770
+ return null;
1771
+ }
1772
+ return this.retrievePermission(permissionID);
1773
+ }
1774
+
1775
+ public async updateServer(
1776
+ serverID: string,
1777
+ update: { icon?: null | string; name?: string },
1778
+ ): Promise<null | Server> {
1779
+ const result = await this.db
1780
+ .updateTable("servers")
1781
+ .set(update)
1782
+ .where("serverID", "=", serverID)
1783
+ .executeTakeFirst();
1784
+ if (Number(result.numUpdatedRows) === 0) {
1785
+ return null;
1786
+ }
1787
+ return this.retrieveServer(serverID);
1788
+ }
1789
+
1734
1790
  public async upsertStoreSubscription(
1735
1791
  input: StoreSubscriptionUpsertInput,
1736
1792
  ): Promise<BillingSubscription> {
@@ -1842,6 +1898,74 @@ export class Database extends EventEmitter {
1842
1898
  this.emit("ready");
1843
1899
  }
1844
1900
 
1901
+ private async insertDevice(
1902
+ trx: Transaction<ServerDatabase>,
1903
+ owner: string,
1904
+ payload: DevicePayload,
1905
+ passkeyApproval?: DevicePasskeyApprovalInput,
1906
+ ): Promise<Device> {
1907
+ const activeDeviceCount = await trx
1908
+ .selectFrom("devices")
1909
+ .select((eb) => eb.fn.countAll().as("count"))
1910
+ .where("owner", "=", owner)
1911
+ .where("deleted", "=", 0)
1912
+ .executeTakeFirst();
1913
+ if (
1914
+ Number(activeDeviceCount?.count ?? 0) >= MAX_ACTIVE_DEVICES_PER_USER
1915
+ ) {
1916
+ throw new Error(
1917
+ `Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices.`,
1918
+ );
1919
+ }
1920
+
1921
+ const now = new Date().toISOString();
1922
+ const device = {
1923
+ deleted: 0,
1924
+ deviceID: crypto.randomUUID(),
1925
+ lastLogin: now,
1926
+ name: payload.deviceName,
1927
+ owner,
1928
+ signKey: payload.signKey,
1929
+ };
1930
+
1931
+ const medPreKeys = {
1932
+ deviceID: device.deviceID,
1933
+ index: payload.preKeyIndex,
1934
+ keyID: crypto.randomUUID(),
1935
+ publicKey: payload.preKey,
1936
+ signature: payload.preKeySignature,
1937
+ userID: owner,
1938
+ };
1939
+
1940
+ await trx.insertInto("devices").values(device).execute();
1941
+ await trx.insertInto("preKeys").values(medPreKeys).execute();
1942
+ if (passkeyApproval) {
1943
+ await trx
1944
+ .insertInto("device_passkey_approvals")
1945
+ .values({
1946
+ approvedAt: now,
1947
+ approvedByDeviceID:
1948
+ passkeyApproval.approvedByDeviceID ?? null,
1949
+ approvedByPasskeyID: passkeyApproval.approvedByPasskeyID,
1950
+ deviceID: device.deviceID,
1951
+ userID: owner,
1952
+ })
1953
+ .onConflict((oc) =>
1954
+ oc.column("deviceID").doUpdateSet({
1955
+ approvedAt: now,
1956
+ approvedByDeviceID:
1957
+ passkeyApproval.approvedByDeviceID ?? null,
1958
+ approvedByPasskeyID:
1959
+ passkeyApproval.approvedByPasskeyID,
1960
+ userID: owner,
1961
+ }),
1962
+ )
1963
+ .execute();
1964
+ }
1965
+
1966
+ return toDevice(device);
1967
+ }
1968
+
1845
1969
  private mailSqlEntry(
1846
1970
  mail: MailWS,
1847
1971
  header: Uint8Array,
@@ -1873,49 +1997,77 @@ export class Database extends EventEmitter {
1873
1997
  * Returns the encoded hash string which embeds salt, params, and digest.
1874
1998
  */
1875
1999
  export async function hashPasswordArgon2(password: string): Promise<string> {
1876
- return argon2.hash(password, {
1877
- memoryCost: 65536,
1878
- parallelism: 4,
1879
- timeCost: 3,
1880
- type: argon2.argon2id,
1881
- });
2000
+ if (
2001
+ password.length === 0 ||
2002
+ password.length > ACCOUNT_PASSWORD_MAX_LENGTH
2003
+ ) {
2004
+ throw new Error("Password length is outside the supported range.");
2005
+ }
2006
+ return argon2.hash(password, ARGON2_OPTIONS);
2007
+ }
2008
+
2009
+ /**
2010
+ * Validate new account passwords without imposing composition rules. Passwords
2011
+ * are hashed exactly as supplied; normalization is used only for comparisons
2012
+ * against account-specific and common-password deny lists.
2013
+ */
2014
+ export function validateAccountPassword(
2015
+ password: string,
2016
+ username?: string,
2017
+ ): null | string {
2018
+ if (
2019
+ password.trim().length === 0 ||
2020
+ password.length < ACCOUNT_PASSWORD_MIN_LENGTH
2021
+ ) {
2022
+ return `Password must be at least ${String(ACCOUNT_PASSWORD_MIN_LENGTH)} characters.`;
2023
+ }
2024
+ if (password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
2025
+ return `Password must be at most ${String(ACCOUNT_PASSWORD_MAX_LENGTH)} characters.`;
2026
+ }
2027
+
2028
+ const comparable = password.normalize("NFKC").toLowerCase();
2029
+ const comparableUsername = username?.trim().normalize("NFKC").toLowerCase();
2030
+ const characters = Array.from(comparable);
2031
+ const repeatedSingleCharacter = characters.every(
2032
+ (character) => character === characters[0],
2033
+ );
2034
+ if (
2035
+ COMMON_ACCOUNT_PASSWORDS.has(comparable) ||
2036
+ repeatedSingleCharacter ||
2037
+ (comparableUsername !== undefined && comparable === comparableUsername)
2038
+ ) {
2039
+ return "Choose a less common password.";
2040
+ }
2041
+
2042
+ return null;
1882
2043
  }
1883
2044
 
1884
2045
  /**
1885
- * Verify a password against either Argon2id or legacy PBKDF2 storage.
1886
- * Returns `{ valid, needsRehash }` — callers should rehash on success
1887
- * when `needsRehash` is true.
2046
+ * Verify an Argon2id password hash. Unknown hash formats fail closed.
1888
2047
  */
1889
2048
  export async function verifyPassword(
1890
2049
  password: string,
1891
- stored: { hashAlgo: string; passwordHash: string; passwordSalt: string },
2050
+ stored: null | {
2051
+ passwordHash: string;
2052
+ },
1892
2053
  ): Promise<{ needsRehash: boolean; valid: boolean }> {
1893
- if (stored.hashAlgo === "keycluster") {
2054
+ if (stored === null) {
2055
+ await argon2.verify(DUMMY_PASSWORD_HASH, password);
1894
2056
  return { needsRehash: false, valid: false };
1895
2057
  }
1896
- if (stored.hashAlgo === "argon2id") {
2058
+ try {
1897
2059
  const valid = await argon2.verify(stored.passwordHash, password);
1898
- return { needsRehash: false, valid };
1899
- }
1900
-
1901
- // Legacy PBKDF2 path
1902
- const salt = XUtils.decodeHex(stored.passwordSalt);
1903
- const computed = pbkdf2Sync(
1904
- password,
1905
- salt,
1906
- PBKDF2_ITERATIONS,
1907
- 32,
1908
- "sha512",
1909
- );
1910
- const storedBuf = XUtils.decodeHex(stored.passwordHash);
1911
-
1912
- if (computed.length !== storedBuf.length) {
2060
+ return {
2061
+ needsRehash:
2062
+ valid &&
2063
+ argon2.needsRehash(stored.passwordHash, ARGON2_OPTIONS),
2064
+ valid,
2065
+ };
2066
+ } catch {
2067
+ // Keep malformed rows on the same expensive path as a missing user.
2068
+ await argon2.verify(DUMMY_PASSWORD_HASH, password).catch(() => false);
1913
2069
  return { needsRehash: false, valid: false };
1914
2070
  }
1915
-
1916
- const { timingSafeEqual } = await import("node:crypto");
1917
- const valid = timingSafeEqual(computed, storedBuf);
1918
- return { needsRehash: valid, valid };
1919
2071
  }
1920
2072
 
1921
2073
  function accountTierRank(tier: AccountTier): number {
@@ -2001,16 +2153,8 @@ function expiryRank(expiresAt: null | string): number {
2001
2153
  // flows) gets the same lowercase canonicalization the public
2002
2154
  // `POST /register` route applies. Usernames are case-insensitive at
2003
2155
  // the protocol level.
2004
- function normalizeRegistrationUsername(
2005
- providedUsername: string | undefined,
2006
- userID: string,
2007
- ): string {
2008
- const trimmed = providedUsername?.trim().toLowerCase();
2009
- if (trimmed && trimmed.length > 0) {
2010
- return trimmed;
2011
- }
2012
- const seed = userID.replaceAll("-", "").slice(0, 12);
2013
- return `key_${seed}`;
2156
+ function normalizeRegistrationUsername(providedUsername: string): string {
2157
+ return providedUsername.trim().toLowerCase();
2014
2158
  }
2015
2159
 
2016
2160
  function parseAccountEntitlementSource(
@@ -2131,10 +2275,8 @@ function toServer(row: {
2131
2275
  }
2132
2276
 
2133
2277
  function toUserRecord(row: {
2134
- hashAlgo: string;
2135
2278
  lastSeen: string;
2136
2279
  passwordHash: string;
2137
- passwordSalt: string;
2138
2280
  userID: string;
2139
2281
  username: string;
2140
2282
  }): InternalUserRecord {