@vex-chat/spire 1.0.0 → 1.0.2

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 (66) hide show
  1. package/README.md +38 -38
  2. package/dist/ClientManager.d.ts +1 -3
  3. package/dist/ClientManager.js +7 -50
  4. package/dist/ClientManager.js.map +1 -1
  5. package/dist/Database.d.ts +49 -6
  6. package/dist/Database.js +255 -80
  7. package/dist/Database.js.map +1 -1
  8. package/dist/Spire.d.ts +0 -3
  9. package/dist/Spire.js +116 -81
  10. package/dist/Spire.js.map +1 -1
  11. package/dist/db/schema.d.ts +1 -0
  12. package/dist/migrations/2026-04-14_argon2id-password-hashing.d.ts +3 -0
  13. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +12 -0
  14. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +1 -0
  15. package/dist/run.js +0 -1
  16. package/dist/run.js.map +1 -1
  17. package/dist/server/avatar.d.ts +1 -3
  18. package/dist/server/avatar.js +7 -11
  19. package/dist/server/avatar.js.map +1 -1
  20. package/dist/server/errors.d.ts +3 -6
  21. package/dist/server/errors.js +2 -28
  22. package/dist/server/errors.js.map +1 -1
  23. package/dist/server/file.d.ts +1 -2
  24. package/dist/server/file.js +5 -7
  25. package/dist/server/file.js.map +1 -1
  26. package/dist/server/index.d.ts +1 -2
  27. package/dist/server/index.js +35 -33
  28. package/dist/server/index.js.map +1 -1
  29. package/dist/server/invite.d.ts +1 -2
  30. package/dist/server/invite.js +1 -1
  31. package/dist/server/invite.js.map +1 -1
  32. package/dist/server/rateLimit.d.ts +13 -3
  33. package/dist/server/rateLimit.js +57 -6
  34. package/dist/server/rateLimit.js.map +1 -1
  35. package/dist/server/user.d.ts +1 -2
  36. package/dist/server/user.js +10 -8
  37. package/dist/server/user.js.map +1 -1
  38. package/dist/utils/jwtSecret.d.ts +3 -3
  39. package/dist/utils/jwtSecret.js +5 -6
  40. package/dist/utils/jwtSecret.js.map +1 -1
  41. package/dist/utils/loadEnv.js +1 -1
  42. package/dist/utils/loadEnv.js.map +1 -1
  43. package/package.json +17 -8
  44. package/src/ClientManager.ts +7 -60
  45. package/src/Database.ts +308 -89
  46. package/src/Spire.ts +122 -119
  47. package/src/__tests__/Database.spec.ts +0 -17
  48. package/src/db/schema.ts +1 -0
  49. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +18 -0
  50. package/src/run.ts +0 -1
  51. package/src/server/avatar.ts +7 -14
  52. package/src/server/errors.ts +3 -32
  53. package/src/server/file.ts +5 -10
  54. package/src/server/index.ts +37 -37
  55. package/src/server/invite.ts +0 -2
  56. package/src/server/rateLimit.ts +43 -9
  57. package/src/server/user.ts +9 -9
  58. package/src/utils/jwtSecret.ts +5 -6
  59. package/src/utils/loadEnv.ts +1 -1
  60. package/dist/__tests__/Database.spec.d.ts +0 -1
  61. package/dist/__tests__/Database.spec.js +0 -136
  62. package/dist/__tests__/Database.spec.js.map +0 -1
  63. package/dist/utils/createLogger.d.ts +0 -5
  64. package/dist/utils/createLogger.js +0 -35
  65. package/dist/utils/createLogger.js.map +0 -1
  66. package/src/utils/createLogger.ts +0 -47
package/src/Database.ts CHANGED
@@ -18,17 +18,19 @@ import type {
18
18
  UserRecord,
19
19
  } from "@vex-chat/types";
20
20
  import type { Migration, MigrationProvider } from "kysely";
21
- import type winston from "winston";
22
21
 
23
22
  import { EventEmitter } from "events";
24
23
  import { pbkdf2Sync } from "node:crypto";
24
+ import { statSync } from "node:fs";
25
25
  import * as fs from "node:fs/promises";
26
26
  import path from "node:path";
27
27
  import { fileURLToPath, pathToFileURL } from "node:url";
28
28
 
29
- import { xMakeNonce, XUtils } from "@vex-chat/crypto";
29
+ import { XUtils } from "@vex-chat/crypto";
30
30
  import { MailType } from "@vex-chat/types";
31
31
 
32
+ import argon2 from "argon2";
33
+
32
34
  /**
33
35
  * Narrow a plain integer from the `mailType` SQL column to the
34
36
  * `MailType` union (0 = initial, 1 = subsequent). Throws if the
@@ -46,8 +48,6 @@ import BetterSqlite3 from "better-sqlite3";
46
48
  import { Kysely, Migrator, sql, SqliteDialect } from "kysely";
47
49
  import { stringify as uuidStringify, validate as uuidValidate } from "uuid";
48
50
 
49
- import { createLogger } from "./utils/createLogger.ts";
50
-
51
51
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
52
52
  const migrationFolder = path.join(__dirname, "migrations");
53
53
 
@@ -104,21 +104,53 @@ function isMigration(mod: unknown): mod is Migration {
104
104
  }
105
105
 
106
106
  const pubkeyRegex = /[0-9a-f]{64}/;
107
- export const ITERATIONS = 1000;
107
+
108
+ /** Legacy iteration count kept only for verifying old PBKDF2 hashes. */
109
+ const PBKDF2_ITERATIONS = 1000;
108
110
 
109
111
  // ── Row-to-interface converters ─────────────────────────────────────────
110
112
  // SQLite stores booleans as integers and dates as strings, but the
111
113
  // @vex-chat/types interfaces expect boolean / Date.
112
114
 
115
+ /** Internal record that includes the hash algorithm discriminator. */
116
+ export interface InternalUserRecord extends UserRecord {
117
+ hashAlgo: string;
118
+ }
119
+
113
120
  export class Database extends EventEmitter {
114
121
  private db: Kysely<ServerDatabase>;
115
- private log: winston.Logger;
122
+
123
+ /** Underlying better-sqlite3 handle (file or :memory:). */
124
+ private readonly rawSqlite: InstanceType<typeof BetterSqlite3>;
125
+
126
+ /**
127
+ * In-process cache for {@link retrieveUserDeviceList} when queried for a
128
+ * single owner. Libvex calls GET /user/:id/devices on every outgoing
129
+ * `forward()`; stress runs otherwise hammer SQLite with identical reads.
130
+ * Cleared on device create/delete and when the DB is closed.
131
+ */
132
+ private readonly userDeviceListCache = new Map<
133
+ string,
134
+ { devices: Device[]; until: number }
135
+ >();
136
+
137
+ /**
138
+ * Short TTL cache for {@link retrieveEmojiList} (key = server id, stored as
139
+ * `emojis.owner`). The noise harness fires `emoji.retrieveList` as a
140
+ * follow-up on ~1/25 successful ops, so this path can contend on SQLite
141
+ * before heavier surfaces. Invalidated on emoji create/delete.
142
+ */
143
+ private readonly emojiListByServerCache = new Map<
144
+ string,
145
+ { rows: Emoji[]; until: number }
146
+ >();
147
+
148
+ private static readonly USER_DEVICE_LIST_CACHE_TTL_MS = 10_000;
149
+ private static readonly EMOJI_LIST_CACHE_TTL_MS = 10_000;
116
150
 
117
151
  constructor(options?: SpireOptions) {
118
152
  super();
119
153
 
120
- this.log = createLogger("spire-db", options?.logLevel || "error");
121
-
122
154
  const dbType = options?.dbType || "sqlite3";
123
155
 
124
156
  let filename: string;
@@ -135,23 +167,86 @@ export class Database extends EventEmitter {
135
167
  break;
136
168
  }
137
169
 
138
- const sqliteDb = new BetterSqlite3(filename);
139
- sqliteDb.pragma("journal_mode = WAL");
140
- sqliteDb.pragma("synchronous = NORMAL");
141
- sqliteDb.pragma("busy_timeout = 5000");
142
- sqliteDb.pragma("cache_size = -64000");
143
- sqliteDb.pragma("temp_store = memory");
144
- sqliteDb.pragma("foreign_keys = ON");
170
+ this.rawSqlite = new BetterSqlite3(filename);
171
+ this.rawSqlite.pragma("journal_mode = WAL");
172
+ this.rawSqlite.pragma("synchronous = NORMAL");
173
+ this.rawSqlite.pragma("busy_timeout = 5000");
174
+ this.rawSqlite.pragma("cache_size = -64000");
175
+ this.rawSqlite.pragma("temp_store = memory");
176
+ this.rawSqlite.pragma("foreign_keys = ON");
145
177
 
146
178
  this.db = new Kysely<ServerDatabase>({
147
- dialect: new SqliteDialect({ database: sqliteDb }),
179
+ dialect: new SqliteDialect({ database: this.rawSqlite }),
148
180
  });
149
181
 
150
182
  void this.init();
151
183
  }
152
184
 
185
+ /**
186
+ * Dev-only snapshot of SQLite files + pragmas (same process as Spire).
187
+ * Not for production callers.
188
+ */
189
+ public getDevSqliteMonitor(): Record<string, unknown> {
190
+ const openedAs = this.rawSqlite.name;
191
+ const absPath =
192
+ openedAs === ":memory:"
193
+ ? ":memory:"
194
+ : path.isAbsolute(openedAs)
195
+ ? openedAs
196
+ : path.resolve(process.cwd(), openedAs);
197
+
198
+ const journalMode = this.rawSqlite.pragma("journal_mode", {
199
+ simple: true,
200
+ });
201
+ const synchronous = this.rawSqlite.pragma("synchronous", {
202
+ simple: true,
203
+ });
204
+ const busyTimeout = this.rawSqlite.pragma("busy_timeout", {
205
+ simple: true,
206
+ });
207
+ const cacheSize = this.rawSqlite.pragma("cache_size", { simple: true });
208
+ const pageCount = this.rawSqlite.pragma("page_count", { simple: true });
209
+ const pageSize = this.rawSqlite.pragma("page_size", { simple: true });
210
+ const freelistCount = this.rawSqlite.pragma("freelist_count", {
211
+ simple: true,
212
+ });
213
+
214
+ const out: Record<string, unknown> = {
215
+ absPath,
216
+ busyTimeout,
217
+ cacheSize,
218
+ freelistCount,
219
+ journalMode,
220
+ openedAs,
221
+ pageCount,
222
+ pageSize,
223
+ synchronous,
224
+ };
225
+
226
+ if (openedAs !== ":memory:") {
227
+ const filePaths = {
228
+ main: absPath,
229
+ shm: `${absPath}-shm`,
230
+ wal: `${absPath}-wal`,
231
+ };
232
+ out["filePaths"] = filePaths;
233
+ const sizes: Record<string, number> = {};
234
+ for (const [label, fp] of Object.entries(filePaths)) {
235
+ try {
236
+ sizes[label] = statSync(fp).size;
237
+ } catch {
238
+ sizes[label] = 0;
239
+ }
240
+ }
241
+ out["fileBytes"] = sizes;
242
+ }
243
+
244
+ return out;
245
+ }
246
+
153
247
  public async close(): Promise<void> {
154
- this.log.info("Closing database.");
248
+ this.userDeviceListCache.clear();
249
+ this.emojiListByServerCache.clear();
155
250
  await this.db.destroy();
156
251
  }
157
252
 
@@ -182,6 +277,7 @@ export class Database extends EventEmitter {
182
277
  };
183
278
 
184
279
  await this.db.insertInto("devices").values(device).execute();
280
+ this.userDeviceListCache.delete(owner);
185
281
 
186
282
  const medPreKeys = {
187
283
  deviceID: device.deviceID,
@@ -199,6 +295,7 @@ export class Database extends EventEmitter {
199
295
 
200
296
  public async createEmoji(emoji: Emoji): Promise<void> {
201
297
  await this.db.insertInto("emojis").values(emoji).execute();
298
+ this.emojiListByServerCache.delete(emoji.owner);
202
299
  }
203
300
 
204
301
  public async createFile(file: FileSQL): Promise<void> {
@@ -228,30 +325,31 @@ export class Database extends EventEmitter {
228
325
  resourceID: string,
229
326
  powerLevel: number,
230
327
  ): Promise<Permission> {
231
- const permissionID = crypto.randomUUID();
232
-
233
- // check if it already exists
234
- const checkPermission = await this.db
235
- .selectFrom("permissions")
236
- .selectAll()
237
- .where("userID", "=", userID)
238
- .where("resourceID", "=", resourceID)
239
- .execute();
240
- const existing = checkPermission[0];
241
- if (existing) {
242
- return existing;
243
- }
328
+ // Atomic check-then-insert inside a transaction so two concurrent
329
+ // callers cannot both find "no existing" and insert duplicates.
330
+ return this.db.transaction().execute(async (trx) => {
331
+ const checkPermission = await trx
332
+ .selectFrom("permissions")
333
+ .selectAll()
334
+ .where("userID", "=", userID)
335
+ .where("resourceID", "=", resourceID)
336
+ .execute();
337
+ const existing = checkPermission[0];
338
+ if (existing) {
339
+ return existing;
340
+ }
244
341
 
245
- const permission: Permission = {
246
- permissionID,
247
- powerLevel,
248
- resourceID,
249
- resourceType,
250
- userID,
251
- };
342
+ const permission: Permission = {
343
+ permissionID: crypto.randomUUID(),
344
+ powerLevel,
345
+ resourceID,
346
+ resourceType,
347
+ userID,
348
+ };
252
349
 
253
- await this.db.insertInto("permissions").values(permission).execute();
254
- return permission;
350
+ await trx.insertInto("permissions").values(permission).execute();
351
+ return permission;
352
+ });
255
353
  }
256
354
 
257
355
  public async createServer(name: string, ownerID: string): Promise<Server> {
@@ -280,13 +378,12 @@ export class Database extends EventEmitter {
280
378
  regPayload: RegistrationPayload,
281
379
  ): Promise<[null | UserRecord, Error | null]> {
282
380
  try {
283
- const salt = xMakeNonce();
284
- const passwordHash = hashPassword(regPayload.password, salt);
381
+ const passwordHash = await hashPasswordArgon2(regPayload.password);
285
382
 
286
383
  const user: UserRecord = {
287
384
  lastSeen: new Date().toISOString(),
288
- passwordHash: passwordHash.toString("hex"),
289
- passwordSalt: XUtils.encodeHex(salt),
385
+ passwordHash,
386
+ passwordSalt: "",
290
387
  userID: uuidStringify(regKey),
291
388
  username: regPayload.username,
292
389
  };
@@ -295,6 +392,7 @@ export class Database extends EventEmitter {
295
392
  .insertInto("users")
296
393
  .values({
297
394
  ...user,
395
+ hashAlgo: "argon2id",
298
396
  lastSeen: user.lastSeen,
299
397
  })
300
398
  .execute();
@@ -319,6 +417,12 @@ export class Database extends EventEmitter {
319
417
  }
320
418
 
321
419
  public async deleteDevice(deviceID: string): Promise<void> {
420
+ const ownerRow = await this.db
421
+ .selectFrom("devices")
422
+ .select("owner")
423
+ .where("deviceID", "=", deviceID)
424
+ .executeTakeFirst();
425
+
322
426
  await this.db
323
427
  .deleteFrom("preKeys")
324
428
  .where("deviceID", "=", deviceID)
@@ -334,13 +438,21 @@ export class Database extends EventEmitter {
334
438
  .set({ deleted: 1 })
335
439
  .where("deviceID", "=", deviceID)
336
440
  .execute();
441
+
442
+ if (ownerRow?.owner) {
443
+ this.userDeviceListCache.delete(ownerRow.owner);
444
+ }
337
445
  }
338
446
 
339
447
  public async deleteEmoji(emojiID: string): Promise<void> {
448
+ const existing = await this.retrieveEmoji(emojiID);
340
449
  await this.db
341
450
  .deleteFrom("emojis")
342
451
  .where("emojiID", "=", emojiID)
343
452
  .execute();
453
+ if (existing) {
454
+ this.emojiListByServerCache.delete(existing.owner);
455
+ }
344
456
  }
345
457
 
346
458
  public async deleteInvite(inviteID: string): Promise<void> {
@@ -403,31 +515,34 @@ export class Database extends EventEmitter {
403
515
  }
404
516
 
405
517
  public async getOTK(deviceID: string): Promise<null | PreKeysWS> {
406
- const rows: PreKeysSQL[] = await this.db
407
- .selectFrom("oneTimeKeys")
408
- .selectAll()
409
- .where("deviceID", "=", deviceID)
410
- .orderBy("index")
411
- .limit(1)
412
- .execute();
413
- const otkInfo = rows[0];
414
- if (!otkInfo) {
415
- return null;
416
- }
417
- const otk: PreKeysWS = {
418
- deviceID: otkInfo.deviceID,
419
- index: otkInfo.index,
420
- publicKey: XUtils.decodeHex(otkInfo.publicKey),
421
- signature: XUtils.decodeHex(otkInfo.signature),
422
- };
518
+ // Atomic select-then-delete inside a transaction so two concurrent
519
+ // callers cannot dispense the same one-time key.
520
+ return this.db.transaction().execute(async (trx) => {
521
+ const rows: PreKeysSQL[] = await trx
522
+ .selectFrom("oneTimeKeys")
523
+ .selectAll()
524
+ .where("deviceID", "=", deviceID)
525
+ .orderBy("index")
526
+ .limit(1)
527
+ .execute();
528
+ const otkInfo = rows[0];
529
+ if (!otkInfo) {
530
+ return null;
531
+ }
423
532
 
424
- // delete the otk
425
- await this.db
426
- .deleteFrom("oneTimeKeys")
427
- .where("deviceID", "=", deviceID)
428
- .where("index", "=", otk.index)
429
- .execute();
430
- return otk;
533
+ await trx
534
+ .deleteFrom("oneTimeKeys")
535
+ .where("deviceID", "=", deviceID)
536
+ .where("index", "=", otkInfo.index)
537
+ .execute();
538
+
539
+ return {
540
+ deviceID: otkInfo.deviceID,
541
+ index: otkInfo.index,
542
+ publicKey: XUtils.decodeHex(otkInfo.publicKey),
543
+ signature: XUtils.decodeHex(otkInfo.signature),
544
+ };
545
+ });
431
546
  }
432
547
 
433
548
  public async getOTKCount(deviceID: string): Promise<number> {
@@ -489,8 +604,8 @@ export class Database extends EventEmitter {
489
604
  try {
490
605
  await sql`select 1 as ok`.execute(this.db);
491
606
  return true;
492
- } catch (err: unknown) {
493
- this.log.warn("Database health check failed: " + String(err));
607
+ } catch (_err: unknown) {
608
+ // debugger: health check failed
494
609
  return false;
495
610
  }
496
611
  }
@@ -511,6 +626,21 @@ export class Database extends EventEmitter {
511
626
  .execute();
512
627
  }
513
628
 
629
+ public async rehashPassword(
630
+ userID: string,
631
+ newHash: string,
632
+ ): Promise<void> {
633
+ await this.db
634
+ .updateTable("users")
635
+ .set({
636
+ hashAlgo: "argon2id",
637
+ passwordHash: newHash,
638
+ passwordSalt: "",
639
+ })
640
+ .where("userID", "=", userID)
641
+ .execute();
642
+ }
643
+
514
644
  /**
515
645
  * Retrives a list of users that should be notified when a specific resourceID
516
646
  * experiences changes.
@@ -588,12 +718,25 @@ export class Database extends EventEmitter {
588
718
  return rows[0] ?? null;
589
719
  }
590
720
 
591
- public async retrieveEmojiList(userID: string): Promise<Emoji[]> {
592
- return this.db
721
+ public async retrieveEmojiList(serverID: string): Promise<Emoji[]> {
722
+ const now = Date.now();
723
+ const hit = this.emojiListByServerCache.get(serverID);
724
+ if (hit && hit.until > now) {
725
+ return hit.rows.map((r) => ({ ...r }));
726
+ }
727
+
728
+ const rows: Emoji[] = await this.db
593
729
  .selectFrom("emojis")
594
730
  .selectAll()
595
- .where("owner", "=", userID)
731
+ .where("owner", "=", serverID)
596
732
  .execute();
733
+
734
+ this.emojiListByServerCache.set(serverID, {
735
+ rows: rows.map((r) => ({ ...r })),
736
+ until: now + Database.EMOJI_LIST_CACHE_TTL_MS,
737
+ });
738
+
739
+ return rows.map((r) => ({ ...r }));
597
740
  }
598
741
 
599
742
  public async retrieveFile(fileID: string): Promise<FileSQL | null> {
@@ -735,17 +878,25 @@ export class Database extends EventEmitter {
735
878
  .where("serverID", "=", serverID)
736
879
  .execute();
737
880
 
738
- return rows.filter((invite: Invite) => {
739
- const valid =
740
- new Date(Date.now()).getTime() <
741
- new Date(invite.expiration).getTime();
742
-
743
- if (!valid) {
744
- void this.deleteInvite(invite.inviteID);
881
+ const nowMs = Date.now();
882
+ const expiredIds: string[] = [];
883
+ const valid: Invite[] = [];
884
+ for (const row of rows) {
885
+ const expMs = new Date(row.expiration).getTime();
886
+ if (!Number.isFinite(expMs) || expMs <= nowMs) {
887
+ expiredIds.push(row.inviteID);
888
+ continue;
745
889
  }
746
-
747
- return valid;
748
- });
890
+ valid.push(row);
891
+ }
892
+ if (expiredIds.length > 0) {
893
+ await this.db
894
+ .deleteFrom("invites")
895
+ .where("serverID", "=", serverID)
896
+ .where("inviteID", "in", expiredIds)
897
+ .execute();
898
+ }
899
+ return valid;
749
900
  }
750
901
 
751
902
  public async retrieveServers(userID: string): Promise<Server[]> {
@@ -763,7 +914,7 @@ export class Database extends EventEmitter {
763
914
  // the identifier can be username, public key, or userID
764
915
  public async retrieveUser(
765
916
  userIdentifier: string,
766
- ): Promise<null | UserRecord> {
917
+ ): Promise<InternalUserRecord | null> {
767
918
  let rows;
768
919
  if (uuidValidate(userIdentifier)) {
769
920
  rows = await this.db
@@ -786,16 +937,39 @@ export class Database extends EventEmitter {
786
937
  }
787
938
 
788
939
  public async retrieveUserDeviceList(userIDs: string[]): Promise<Device[]> {
940
+ const now = Date.now();
941
+ if (userIDs.length === 1) {
942
+ const owner = userIDs[0];
943
+ if (typeof owner === "string") {
944
+ const hit = this.userDeviceListCache.get(owner);
945
+ if (hit && hit.until > now) {
946
+ return hit.devices.map((d) => ({ ...d }));
947
+ }
948
+ }
949
+ }
950
+
789
951
  const rows = await this.db
790
952
  .selectFrom("devices")
791
953
  .selectAll()
792
954
  .where("owner", "in", userIDs)
793
955
  .where("deleted", "=", 0)
794
956
  .execute();
795
- return rows.map(toDevice);
957
+ const devices = rows.map(toDevice);
958
+
959
+ if (userIDs.length === 1) {
960
+ const owner = userIDs[0];
961
+ if (typeof owner === "string") {
962
+ this.userDeviceListCache.set(owner, {
963
+ devices: devices.map((d) => ({ ...d })),
964
+ until: now + Database.USER_DEVICE_LIST_CACHE_TTL_MS,
965
+ });
966
+ }
967
+ }
968
+
969
+ return devices;
796
970
  }
797
971
 
798
- public async retrieveUsers(): Promise<UserRecord[]> {
972
+ public async retrieveUsers(): Promise<InternalUserRecord[]> {
799
973
  const rows = await this.db.selectFrom("users").selectAll().execute();
800
974
  return rows.map(toUserRecord);
801
975
  }
@@ -864,6 +1038,53 @@ export class Database extends EventEmitter {
864
1038
  }
865
1039
  }
866
1040
 
1041
+ /**
1042
+ * Hash a password with Argon2id (new default).
1043
+ * Returns the encoded hash string which embeds salt, params, and digest.
1044
+ */
1045
+ export async function hashPasswordArgon2(password: string): Promise<string> {
1046
+ return argon2.hash(password, {
1047
+ memoryCost: 65536,
1048
+ parallelism: 4,
1049
+ timeCost: 3,
1050
+ type: argon2.argon2id,
1051
+ });
1052
+ }
1053
+
1054
+ /**
1055
+ * Verify a password against either Argon2id or legacy PBKDF2 storage.
1056
+ * Returns `{ valid, needsRehash }` — callers should rehash on success
1057
+ * when `needsRehash` is true.
1058
+ */
1059
+ export async function verifyPassword(
1060
+ password: string,
1061
+ stored: { hashAlgo: string; passwordHash: string; passwordSalt: string },
1062
+ ): Promise<{ needsRehash: boolean; valid: boolean }> {
1063
+ if (stored.hashAlgo === "argon2id") {
1064
+ const valid = await argon2.verify(stored.passwordHash, password);
1065
+ return { needsRehash: false, valid };
1066
+ }
1067
+
1068
+ // Legacy PBKDF2 path
1069
+ const salt = XUtils.decodeHex(stored.passwordSalt);
1070
+ const computed = pbkdf2Sync(
1071
+ password,
1072
+ salt,
1073
+ PBKDF2_ITERATIONS,
1074
+ 32,
1075
+ "sha512",
1076
+ );
1077
+ const storedBuf = XUtils.decodeHex(stored.passwordHash);
1078
+
1079
+ if (computed.length !== storedBuf.length) {
1080
+ return { needsRehash: false, valid: false };
1081
+ }
1082
+
1083
+ const { timingSafeEqual } = await import("node:crypto");
1084
+ const valid = timingSafeEqual(computed, storedBuf);
1085
+ return { needsRehash: valid, valid };
1086
+ }
1087
+
867
1088
  function toDevice(row: {
868
1089
  deleted: number;
869
1090
  deviceID: string;
@@ -912,14 +1133,12 @@ function toServer(row: {
912
1133
  }
913
1134
 
914
1135
  function toUserRecord(row: {
1136
+ hashAlgo: string;
915
1137
  lastSeen: string;
916
1138
  passwordHash: string;
917
1139
  passwordSalt: string;
918
1140
  userID: string;
919
1141
  username: string;
920
- }): UserRecord {
1142
+ }): InternalUserRecord {
921
1143
  return { ...row };
922
1144
  }
923
-
924
- export const hashPassword = (password: string, salt: Uint8Array) =>
925
- pbkdf2Sync(password, salt, ITERATIONS, 32, "sha512");