@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/README.md CHANGED
@@ -72,14 +72,15 @@ Spire reads configuration from environment variables. **Docker Compose:** put th
72
72
 
73
73
  ### Optional
74
74
 
75
- | Variable | Default | Description |
76
- | -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
77
- | `SPIRE_FIPS` | _falsy_ | If `true` or `1`, run the **FIPS** profile (P-256, Web Crypto). `SPK` must come from `pnpm --filter @vex-chat/spire gen-spk-fips`, not `gen-spk`. Any other value uses **tweetnacl** (Ed25519) and `gen-spk`. In Docker Compose, the service passes `SPIRE_FIPS=${SPIRE_FIPS:-false}` so the shell or `.env` can set the mode. |
78
- | `API_PORT` | (see text) | If unset, Spire listens on **16777** (all crypto profiles; use `GET /status` to see which). In Docker, nginx and the image healthcheck use `deploy/resolve-spire-listen-port.sh` to follow the same rule. Set explicitly to override. |
79
- | `NODE_ENV` | _(unset)_ | Set to `production` to disable interactive `/docs` / `/async-docs`. If unset or any other value, doc viewers are mounted. `helmet()` runs in all modes. |
80
- | `CORS_ORIGINS` | _(empty)_ | Comma-separated allowed `Origin` values. If set, only those origins may use credentialed browser requests. If unset, Spire **reflects the request `Origin`** so self-hosted Spire and arbitrary app origins (Tauri, localhost, etc.) work without configuration appropriate for bearer-token APIs; set an allowlist if you need to restrict which sites may call your instance from users' browsers. |
81
- | `DEV_API_KEY` | _(empty)_ | When set, requests that send header `x-dev-api-key` with the same value (constant-time compare) **skip in-process rate limiters**. The same gate enables **`GET /status/process`** (404 without a valid key): a small JSON snapshot of the Spire Node process (PID, uptime, `memoryUsage`, cumulative `resourceUsage`, WebSocket client count). Dev / load-testing only never set in production. |
82
- | `CANARY` | _(unset)_ | |
75
+ | Variable | Default | Description |
76
+ | ------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
77
+ | `SPIRE_FIPS` | _falsy_ | If `true` or `1`, run the **FIPS** profile (P-256, Web Crypto). `SPK` must come from `pnpm --filter @vex-chat/spire gen-spk-fips`, not `gen-spk`. Any other value uses **tweetnacl** (Ed25519) and `gen-spk`. In Docker Compose, the service passes `SPIRE_FIPS=${SPIRE_FIPS:-false}` so the shell or `.env` can set the mode. |
78
+ | `API_PORT` | (see text) | If unset, Spire listens on **16777** (all crypto profiles; use `GET /status` to see which). In Docker, nginx and the image healthcheck use `deploy/resolve-spire-listen-port.sh` to follow the same rule. Set explicitly to override. |
79
+ | `NODE_ENV` | _(unset)_ | Set to `production` to disable interactive `/docs` / `/async-docs`. If unset or any other value, doc viewers are mounted. `helmet()` runs in all modes. |
80
+ | `CORS_ORIGINS` | _(empty)_ | Comma-separated allowed `Origin` values. In production, an empty value rejects cross-origin browser requests; same-origin and native clients are unaffected. Development permits only localhost, loopback, Tauri, and Capacitor origins when unset. |
81
+ | `SPIRE_TRUST_PROXY_HOPS` | `0` | Number of trusted reverse-proxy hops. Set `1` for the bundled single-nginx topology. This must match the real network path so clients cannot spoof `X-Forwarded-For` to evade IP rate limits. |
82
+ | `DEV_API_KEY` | _(empty)_ | When set, requests that send header `x-dev-api-key` with the same value (constant-time compare) **skip in-process rate limiters**. The same gate enables **`GET /status/process`** (404 without a valid key): a small JSON snapshot of the Spire Node process (PID, uptime, `memoryUsage`, cumulative `resourceUsage`, WebSocket client count). Dev / load-testing only — never set in production. |
83
+ | `CANARY` | _(unset)_ | |
83
84
 
84
85
  ### Passkeys / WebAuthn
85
86
 
@@ -28,10 +28,8 @@ export interface StoreTransactionRecordInput {
28
28
  subscriptionID: string;
29
29
  userID: string;
30
30
  }
31
- /** Internal record that includes the hash algorithm discriminator. */
32
- export interface InternalUserRecord extends UserRecord {
33
- hashAlgo: string;
34
- }
31
+ export declare const MAX_ACTIVE_DEVICES_PER_USER = 20;
32
+ export type InternalUserRecord = UserRecord;
35
33
  export interface NotificationSubscription {
36
34
  channel: "expo";
37
35
  createdAt: string;
@@ -117,7 +115,7 @@ export declare class Database extends EventEmitter {
117
115
  * WebAuthn spec (FIDO authenticators that report 0 signal they
118
116
  * don't track a counter — callers should treat 0→0 as legitimate).
119
117
  */
120
- markPasskeyUsed(passkeyID: string, signCount: number): Promise<void>;
118
+ markPasskeyUsed(passkeyID: string, expectedSignCount: number, signCount: number): Promise<boolean>;
121
119
  markUserSeen(user: UserRecord): Promise<void>;
122
120
  /** Deletes server-side mail older than the configured retention window. */
123
121
  pruneExpiredMail(): Promise<void>;
@@ -216,6 +214,7 @@ export declare class Database extends EventEmitter {
216
214
  upsertStoreSubscription(input: StoreSubscriptionUpsertInput): Promise<BillingSubscription>;
217
215
  private findExistingStoreSubscription;
218
216
  private init;
217
+ private insertDevice;
219
218
  private mailSqlEntry;
220
219
  }
221
220
  /**
@@ -224,14 +223,16 @@ export declare class Database extends EventEmitter {
224
223
  */
225
224
  export declare function hashPasswordArgon2(password: string): Promise<string>;
226
225
  /**
227
- * Verify a password against either Argon2id or legacy PBKDF2 storage.
228
- * Returns `{ valid, needsRehash }` callers should rehash on success
229
- * when `needsRehash` is true.
226
+ * Validate new account passwords without imposing composition rules. Passwords
227
+ * are hashed exactly as supplied; normalization is used only for comparisons
228
+ * against account-specific and common-password deny lists.
229
+ */
230
+ export declare function validateAccountPassword(password: string, username?: string): null | string;
231
+ /**
232
+ * Verify an Argon2id password hash. Unknown hash formats fail closed.
230
233
  */
231
- export declare function verifyPassword(password: string, stored: {
232
- hashAlgo: string;
234
+ export declare function verifyPassword(password: string, stored: null | {
233
235
  passwordHash: string;
234
- passwordSalt: string;
235
236
  }): Promise<{
236
237
  needsRehash: boolean;
237
238
  valid: boolean;
package/dist/Database.js CHANGED
@@ -12,13 +12,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
12
12
  return path;
13
13
  };
14
14
  import { EventEmitter } from "events";
15
- import { createHash, pbkdf2Sync } from "node:crypto";
15
+ import { createHash } from "node:crypto";
16
16
  import { statSync } from "node:fs";
17
17
  import * as fs from "node:fs/promises";
18
18
  import path from "node:path";
19
19
  import { fileURLToPath, pathToFileURL } from "node:url";
20
20
  import { fipsEcdhRawPublicKeyFromEcdsaSpkiAsync, getCryptoProfile, XUtils, } from "@vex-chat/crypto";
21
- import { AccountEntitlementSourceSchema, AccountTierSchema, BillingEnvironmentSchema, BillingPlatformSchema, BillingSubscriptionStatusSchema, buildAccountEntitlements, MailType, } from "@vex-chat/types";
21
+ import { ACCOUNT_PASSWORD_MAX_LENGTH, ACCOUNT_PASSWORD_MIN_LENGTH, AccountEntitlementSourceSchema, AccountTierSchema, BillingEnvironmentSchema, BillingPlatformSchema, BillingSubscriptionStatusSchema, buildAccountEntitlements, MailType, } from "@vex-chat/types";
22
22
  import argon2 from "argon2";
23
23
  import { serverMailRetentionCutoffIso } from "./mailRetention.js";
24
24
  /**
@@ -38,6 +38,7 @@ function parseMailType(n) {
38
38
  import BetterSqlite3 from "better-sqlite3";
39
39
  import { Kysely, Migrator, sql, SqliteDialect } from "kysely";
40
40
  import { stringify as uuidStringify, validate as uuidValidate } from "uuid";
41
+ export const MAX_ACTIVE_DEVICES_PER_USER = 20;
41
42
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
43
  const migrationFolder = path.join(__dirname, "migrations");
43
44
  /**
@@ -86,9 +87,22 @@ function isMigration(mod) {
86
87
  "up" in mod &&
87
88
  typeof mod.up === "function");
88
89
  }
89
- const pubkeyRegex = /[0-9a-f]{64}/;
90
- /** Legacy iteration count kept only for verifying old PBKDF2 hashes. */
91
- const PBKDF2_ITERATIONS = 1000;
90
+ const pubkeyRegex = /^(?:[0-9a-fA-F]{2}){32,4096}$/;
91
+ const DUMMY_PASSWORD_HASH = "$argon2id$v=19$m=65536,t=3,p=1$SZlCYiWwt450ZWt2zRvDIw$QZdY/EtG81hEAXYRLVDvqtpJbajXL1/QRM91ZT9DQPk";
92
+ const ARGON2_OPTIONS = {
93
+ memoryCost: 65_536,
94
+ parallelism: 1,
95
+ timeCost: 3,
96
+ type: argon2.argon2id,
97
+ };
98
+ const COMMON_ACCOUNT_PASSWORDS = new Set([
99
+ "123456789012345",
100
+ "adminadminadminadmin",
101
+ "letmeinletmeinletmein",
102
+ "passwordpassword",
103
+ "passwordpasswordpassword",
104
+ "qwertyuiopasdfgh",
105
+ ]);
92
106
  export class Database extends EventEmitter {
93
107
  static EMOJI_LIST_CACHE_TTL_MS = 10_000;
94
108
  db;
@@ -143,46 +157,9 @@ export class Database extends EventEmitter {
143
157
  return channel;
144
158
  }
145
159
  async createDevice(owner, payload, passkeyApproval) {
146
- const now = new Date().toISOString();
147
- const device = {
148
- deleted: 0,
149
- deviceID: crypto.randomUUID(),
150
- lastLogin: now,
151
- name: payload.deviceName,
152
- owner,
153
- signKey: payload.signKey,
154
- };
155
- const medPreKeys = {
156
- deviceID: device.deviceID,
157
- index: payload.preKeyIndex,
158
- keyID: crypto.randomUUID(),
159
- publicKey: payload.preKey,
160
- signature: payload.preKeySignature,
161
- userID: owner,
162
- };
163
- await this.db.transaction().execute(async (trx) => {
164
- await trx.insertInto("devices").values(device).execute();
165
- await trx.insertInto("preKeys").values(medPreKeys).execute();
166
- if (passkeyApproval) {
167
- await trx
168
- .insertInto("device_passkey_approvals")
169
- .values({
170
- approvedAt: now,
171
- approvedByDeviceID: passkeyApproval.approvedByDeviceID ?? null,
172
- approvedByPasskeyID: passkeyApproval.approvedByPasskeyID,
173
- deviceID: device.deviceID,
174
- userID: owner,
175
- })
176
- .onConflict((oc) => oc.column("deviceID").doUpdateSet({
177
- approvedAt: now,
178
- approvedByDeviceID: passkeyApproval.approvedByDeviceID ?? null,
179
- approvedByPasskeyID: passkeyApproval.approvedByPasskeyID,
180
- userID: owner,
181
- }))
182
- .execute();
183
- }
184
- });
185
- return toDevice(device);
160
+ return this.db
161
+ .transaction()
162
+ .execute(async (trx) => this.insertDevice(trx, owner, payload, passkeyApproval));
186
163
  }
187
164
  async createEmoji(emoji) {
188
165
  await this.db.insertInto("emojis").values(emoji).execute();
@@ -284,28 +261,32 @@ export class Database extends EventEmitter {
284
261
  async createUser(regKey, regPayload) {
285
262
  try {
286
263
  const userID = uuidStringify(regKey);
287
- const username = normalizeRegistrationUsername(regPayload.username, userID);
264
+ const username = normalizeRegistrationUsername(regPayload.username);
288
265
  if (typeof regPayload.password !== "string" ||
289
266
  regPayload.password.trim().length === 0) {
290
267
  throw new Error("Password is required to register a new account.");
291
268
  }
269
+ const passwordError = validateAccountPassword(regPayload.password, username);
270
+ if (passwordError) {
271
+ throw new Error(passwordError);
272
+ }
292
273
  const passwordHash = await hashPasswordArgon2(regPayload.password);
293
274
  const user = {
294
275
  lastSeen: new Date().toISOString(),
295
276
  passwordHash,
296
- passwordSalt: "",
297
277
  userID,
298
278
  username,
299
279
  };
300
- await this.db
301
- .insertInto("users")
302
- .values({
303
- ...user,
304
- hashAlgo: "argon2id",
305
- lastSeen: user.lastSeen,
306
- })
307
- .execute();
308
- await this.createDevice(user.userID, regPayload);
280
+ await this.db.transaction().execute(async (trx) => {
281
+ await trx
282
+ .insertInto("users")
283
+ .values({
284
+ ...user,
285
+ lastSeen: user.lastSeen,
286
+ })
287
+ .execute();
288
+ await this.insertDevice(trx, user.userID, regPayload);
289
+ });
309
290
  return [user, null];
310
291
  }
311
292
  catch (err) {
@@ -324,27 +305,29 @@ export class Database extends EventEmitter {
324
305
  .execute();
325
306
  }
326
307
  async deleteDevice(deviceID) {
327
- await this.db
328
- .deleteFrom("preKeys")
329
- .where("deviceID", "=", deviceID)
330
- .execute();
331
- await this.db
332
- .deleteFrom("oneTimeKeys")
333
- .where("deviceID", "=", deviceID)
334
- .execute();
335
- await this.db
336
- .deleteFrom("notification_subscriptions")
337
- .where("deviceID", "=", deviceID)
338
- .execute();
339
- await this.db
340
- .deleteFrom("device_passkey_approvals")
341
- .where("deviceID", "=", deviceID)
342
- .execute();
343
- await this.db
344
- .updateTable("devices")
345
- .set({ deleted: 1 })
346
- .where("deviceID", "=", deviceID)
347
- .execute();
308
+ await this.db.transaction().execute(async (trx) => {
309
+ await trx
310
+ .deleteFrom("preKeys")
311
+ .where("deviceID", "=", deviceID)
312
+ .execute();
313
+ await trx
314
+ .deleteFrom("oneTimeKeys")
315
+ .where("deviceID", "=", deviceID)
316
+ .execute();
317
+ await trx
318
+ .deleteFrom("notification_subscriptions")
319
+ .where("deviceID", "=", deviceID)
320
+ .execute();
321
+ await trx
322
+ .deleteFrom("device_passkey_approvals")
323
+ .where("deviceID", "=", deviceID)
324
+ .execute();
325
+ await trx
326
+ .updateTable("devices")
327
+ .set({ deleted: 1 })
328
+ .where("deviceID", "=", deviceID)
329
+ .execute();
330
+ });
348
331
  }
349
332
  async deleteEmoji(emojiID) {
350
333
  const existing = await this.retrieveEmoji(emojiID);
@@ -598,15 +581,17 @@ export class Database extends EventEmitter {
598
581
  * WebAuthn spec (FIDO authenticators that report 0 signal they
599
582
  * don't track a counter — callers should treat 0→0 as legitimate).
600
583
  */
601
- async markPasskeyUsed(passkeyID, signCount) {
602
- await this.db
584
+ async markPasskeyUsed(passkeyID, expectedSignCount, signCount) {
585
+ const result = await this.db
603
586
  .updateTable("passkeys")
604
587
  .set({
605
588
  lastUsedAt: new Date().toISOString(),
606
589
  signCount,
607
590
  })
608
591
  .where("passkeyID", "=", passkeyID)
609
- .execute();
592
+ .where("signCount", "=", expectedSignCount)
593
+ .executeTakeFirst();
594
+ return Number(result.numUpdatedRows) > 0;
610
595
  }
611
596
  async markUserSeen(user) {
612
597
  await this.db
@@ -761,9 +746,7 @@ export class Database extends EventEmitter {
761
746
  await this.db
762
747
  .updateTable("users")
763
748
  .set({
764
- hashAlgo: "argon2id",
765
749
  passwordHash: newHash,
766
- passwordSalt: "",
767
750
  })
768
751
  .where("userID", "=", userID)
769
752
  .execute();
@@ -1170,13 +1153,8 @@ export class Database extends EventEmitter {
1170
1153
  .executeTakeFirst();
1171
1154
  return row?.userID ?? null;
1172
1155
  }
1173
- // The identifier is matched as either a userID (UUID branch) or a
1174
- // username (string branch). Username comparison is case-folded so
1175
- // `User` and `user` resolve to the same row regardless of how the
1176
- // caller typed it — the canonical form on disk is lowercase
1177
- // (`normalizeRegistrationUsername`), but legacy mixed-case rows
1178
- // from before the canonicalization landed still resolve via the
1179
- // `lower(username) = lower(?)` predicate below.
1156
+ // The identifier is matched as either a userID or the canonical lowercase
1157
+ // username stored by `normalizeRegistrationUsername`.
1180
1158
  async retrieveUser(userIdentifier) {
1181
1159
  let rows;
1182
1160
  if (uuidValidate(userIdentifier)) {
@@ -1188,11 +1166,11 @@ export class Database extends EventEmitter {
1188
1166
  .execute();
1189
1167
  }
1190
1168
  else {
1191
- const normalized = userIdentifier.toLowerCase();
1169
+ const normalized = userIdentifier.trim().toLowerCase();
1192
1170
  rows = await this.db
1193
1171
  .selectFrom("users")
1194
1172
  .selectAll()
1195
- .where(sql `lower(username)`, "=", normalized)
1173
+ .where("username", "=", normalized)
1196
1174
  .limit(1)
1197
1175
  .execute();
1198
1176
  }
@@ -1414,6 +1392,55 @@ export class Database extends EventEmitter {
1414
1392
  }
1415
1393
  this.emit("ready");
1416
1394
  }
1395
+ async insertDevice(trx, owner, payload, passkeyApproval) {
1396
+ const activeDeviceCount = await trx
1397
+ .selectFrom("devices")
1398
+ .select((eb) => eb.fn.countAll().as("count"))
1399
+ .where("owner", "=", owner)
1400
+ .where("deleted", "=", 0)
1401
+ .executeTakeFirst();
1402
+ if (Number(activeDeviceCount?.count ?? 0) >= MAX_ACTIVE_DEVICES_PER_USER) {
1403
+ throw new Error(`Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices.`);
1404
+ }
1405
+ const now = new Date().toISOString();
1406
+ const device = {
1407
+ deleted: 0,
1408
+ deviceID: crypto.randomUUID(),
1409
+ lastLogin: now,
1410
+ name: payload.deviceName,
1411
+ owner,
1412
+ signKey: payload.signKey,
1413
+ };
1414
+ const medPreKeys = {
1415
+ deviceID: device.deviceID,
1416
+ index: payload.preKeyIndex,
1417
+ keyID: crypto.randomUUID(),
1418
+ publicKey: payload.preKey,
1419
+ signature: payload.preKeySignature,
1420
+ userID: owner,
1421
+ };
1422
+ await trx.insertInto("devices").values(device).execute();
1423
+ await trx.insertInto("preKeys").values(medPreKeys).execute();
1424
+ if (passkeyApproval) {
1425
+ await trx
1426
+ .insertInto("device_passkey_approvals")
1427
+ .values({
1428
+ approvedAt: now,
1429
+ approvedByDeviceID: passkeyApproval.approvedByDeviceID ?? null,
1430
+ approvedByPasskeyID: passkeyApproval.approvedByPasskeyID,
1431
+ deviceID: device.deviceID,
1432
+ userID: owner,
1433
+ })
1434
+ .onConflict((oc) => oc.column("deviceID").doUpdateSet({
1435
+ approvedAt: now,
1436
+ approvedByDeviceID: passkeyApproval.approvedByDeviceID ?? null,
1437
+ approvedByPasskeyID: passkeyApproval.approvedByPasskeyID,
1438
+ userID: owner,
1439
+ }))
1440
+ .execute();
1441
+ }
1442
+ return toDevice(device);
1443
+ }
1417
1444
  mailSqlEntry(mail, header, deviceID, userID, time = new Date().toISOString()) {
1418
1445
  return {
1419
1446
  authorID: userID,
@@ -1438,36 +1465,57 @@ export class Database extends EventEmitter {
1438
1465
  * Returns the encoded hash string which embeds salt, params, and digest.
1439
1466
  */
1440
1467
  export async function hashPasswordArgon2(password) {
1441
- return argon2.hash(password, {
1442
- memoryCost: 65536,
1443
- parallelism: 4,
1444
- timeCost: 3,
1445
- type: argon2.argon2id,
1446
- });
1468
+ if (password.length === 0 ||
1469
+ password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
1470
+ throw new Error("Password length is outside the supported range.");
1471
+ }
1472
+ return argon2.hash(password, ARGON2_OPTIONS);
1447
1473
  }
1448
1474
  /**
1449
- * Verify a password against either Argon2id or legacy PBKDF2 storage.
1450
- * Returns `{ valid, needsRehash }` callers should rehash on success
1451
- * when `needsRehash` is true.
1475
+ * Validate new account passwords without imposing composition rules. Passwords
1476
+ * are hashed exactly as supplied; normalization is used only for comparisons
1477
+ * against account-specific and common-password deny lists.
1478
+ */
1479
+ export function validateAccountPassword(password, username) {
1480
+ if (password.trim().length === 0 ||
1481
+ password.length < ACCOUNT_PASSWORD_MIN_LENGTH) {
1482
+ return `Password must be at least ${String(ACCOUNT_PASSWORD_MIN_LENGTH)} characters.`;
1483
+ }
1484
+ if (password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
1485
+ return `Password must be at most ${String(ACCOUNT_PASSWORD_MAX_LENGTH)} characters.`;
1486
+ }
1487
+ const comparable = password.normalize("NFKC").toLowerCase();
1488
+ const comparableUsername = username?.trim().normalize("NFKC").toLowerCase();
1489
+ const characters = Array.from(comparable);
1490
+ const repeatedSingleCharacter = characters.every((character) => character === characters[0]);
1491
+ if (COMMON_ACCOUNT_PASSWORDS.has(comparable) ||
1492
+ repeatedSingleCharacter ||
1493
+ (comparableUsername !== undefined && comparable === comparableUsername)) {
1494
+ return "Choose a less common password.";
1495
+ }
1496
+ return null;
1497
+ }
1498
+ /**
1499
+ * Verify an Argon2id password hash. Unknown hash formats fail closed.
1452
1500
  */
1453
1501
  export async function verifyPassword(password, stored) {
1454
- if (stored.hashAlgo === "keycluster") {
1502
+ if (stored === null) {
1503
+ await argon2.verify(DUMMY_PASSWORD_HASH, password);
1455
1504
  return { needsRehash: false, valid: false };
1456
1505
  }
1457
- if (stored.hashAlgo === "argon2id") {
1506
+ try {
1458
1507
  const valid = await argon2.verify(stored.passwordHash, password);
1459
- return { needsRehash: false, valid };
1508
+ return {
1509
+ needsRehash: valid &&
1510
+ argon2.needsRehash(stored.passwordHash, ARGON2_OPTIONS),
1511
+ valid,
1512
+ };
1460
1513
  }
1461
- // Legacy PBKDF2 path
1462
- const salt = XUtils.decodeHex(stored.passwordSalt);
1463
- const computed = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, 32, "sha512");
1464
- const storedBuf = XUtils.decodeHex(stored.passwordHash);
1465
- if (computed.length !== storedBuf.length) {
1514
+ catch {
1515
+ // Keep malformed rows on the same expensive path as a missing user.
1516
+ await argon2.verify(DUMMY_PASSWORD_HASH, password).catch(() => false);
1466
1517
  return { needsRehash: false, valid: false };
1467
1518
  }
1468
- const { timingSafeEqual } = await import("node:crypto");
1469
- const valid = timingSafeEqual(computed, storedBuf);
1470
- return { needsRehash: valid, valid };
1471
1519
  }
1472
1520
  function accountTierRank(tier) {
1473
1521
  switch (tier) {
@@ -1530,13 +1578,8 @@ function expiryRank(expiresAt) {
1530
1578
  // flows) gets the same lowercase canonicalization the public
1531
1579
  // `POST /register` route applies. Usernames are case-insensitive at
1532
1580
  // the protocol level.
1533
- function normalizeRegistrationUsername(providedUsername, userID) {
1534
- const trimmed = providedUsername?.trim().toLowerCase();
1535
- if (trimmed && trimmed.length > 0) {
1536
- return trimmed;
1537
- }
1538
- const seed = userID.replaceAll("-", "").slice(0, 12);
1539
- return `key_${seed}`;
1581
+ function normalizeRegistrationUsername(providedUsername) {
1582
+ return providedUsername.trim().toLowerCase();
1540
1583
  }
1541
1584
  function parseAccountEntitlementSource(source) {
1542
1585
  const parsed = AccountEntitlementSourceSchema.safeParse(source);