@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/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;
@@ -88,6 +86,8 @@ export declare class Database extends EventEmitter {
88
86
  createServer(name: string, ownerID: string): Promise<Server>;
89
87
  createUser(regKey: Uint8Array, regPayload: RegistrationPayload): Promise<[null | UserRecord, Error | null]>;
90
88
  deleteChannel(channelID: string): Promise<void>;
89
+ /** Delete a channel while preserving the invariant that a server has one. */
90
+ deleteChannelIfNotLast(channelID: string): Promise<boolean>;
91
91
  deleteDevice(deviceID: string): Promise<void>;
92
92
  deleteEmoji(emojiID: string): Promise<void>;
93
93
  deleteInvite(inviteID: string): Promise<void>;
@@ -117,7 +117,7 @@ export declare class Database extends EventEmitter {
117
117
  * WebAuthn spec (FIDO authenticators that report 0 signal they
118
118
  * don't track a counter — callers should treat 0→0 as legitimate).
119
119
  */
120
- markPasskeyUsed(passkeyID: string, signCount: number): Promise<void>;
120
+ markPasskeyUsed(passkeyID: string, expectedSignCount: number, signCount: number): Promise<boolean>;
121
121
  markUserSeen(user: UserRecord): Promise<void>;
122
122
  /** Deletes server-side mail older than the configured retention window. */
123
123
  pruneExpiredMail(): Promise<void>;
@@ -213,9 +213,16 @@ export declare class Database extends EventEmitter {
213
213
  expiresAt?: null | string | undefined;
214
214
  source?: AccountEntitlementSource | undefined;
215
215
  }): Promise<AccountEntitlements>;
216
+ updateChannel(channelID: string, name: string): Promise<Channel | null>;
217
+ updatePermissionPowerLevel(permissionID: string, powerLevel: number): Promise<null | Permission>;
218
+ updateServer(serverID: string, update: {
219
+ icon?: null | string;
220
+ name?: string;
221
+ }): Promise<null | Server>;
216
222
  upsertStoreSubscription(input: StoreSubscriptionUpsertInput): Promise<BillingSubscription>;
217
223
  private findExistingStoreSubscription;
218
224
  private init;
225
+ private insertDevice;
219
226
  private mailSqlEntry;
220
227
  }
221
228
  /**
@@ -224,14 +231,16 @@ export declare class Database extends EventEmitter {
224
231
  */
225
232
  export declare function hashPasswordArgon2(password: string): Promise<string>;
226
233
  /**
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.
234
+ * Validate new account passwords without imposing composition rules. Passwords
235
+ * are hashed exactly as supplied; normalization is used only for comparisons
236
+ * against account-specific and common-password deny lists.
237
+ */
238
+ export declare function validateAccountPassword(password: string, username?: string): null | string;
239
+ /**
240
+ * Verify an Argon2id password hash. Unknown hash formats fail closed.
230
241
  */
231
- export declare function verifyPassword(password: string, stored: {
232
- hashAlgo: string;
242
+ export declare function verifyPassword(password: string, stored: null | {
233
243
  passwordHash: string;
234
- passwordSalt: string;
235
244
  }): Promise<{
236
245
  needsRehash: boolean;
237
246
  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) {
@@ -323,28 +304,65 @@ export class Database extends EventEmitter {
323
304
  .where("channelID", "=", channelID)
324
305
  .execute();
325
306
  }
307
+ /** Delete a channel while preserving the invariant that a server has one. */
308
+ async deleteChannelIfNotLast(channelID) {
309
+ return this.db.transaction().execute(async (trx) => {
310
+ const channel = await trx
311
+ .selectFrom("channels")
312
+ .selectAll()
313
+ .where("channelID", "=", channelID)
314
+ .executeTakeFirst();
315
+ if (!channel) {
316
+ return false;
317
+ }
318
+ const siblings = await trx
319
+ .selectFrom("channels")
320
+ .select("channelID")
321
+ .where("serverID", "=", channel.serverID)
322
+ .limit(2)
323
+ .execute();
324
+ if (siblings.length <= 1) {
325
+ return false;
326
+ }
327
+ await trx
328
+ .deleteFrom("permissions")
329
+ .where("resourceID", "=", channelID)
330
+ .execute();
331
+ await trx
332
+ .deleteFrom("mail")
333
+ .where("group", "=", channelID)
334
+ .execute();
335
+ await trx
336
+ .deleteFrom("channels")
337
+ .where("channelID", "=", channelID)
338
+ .execute();
339
+ return true;
340
+ });
341
+ }
326
342
  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();
343
+ await this.db.transaction().execute(async (trx) => {
344
+ await trx
345
+ .deleteFrom("preKeys")
346
+ .where("deviceID", "=", deviceID)
347
+ .execute();
348
+ await trx
349
+ .deleteFrom("oneTimeKeys")
350
+ .where("deviceID", "=", deviceID)
351
+ .execute();
352
+ await trx
353
+ .deleteFrom("notification_subscriptions")
354
+ .where("deviceID", "=", deviceID)
355
+ .execute();
356
+ await trx
357
+ .deleteFrom("device_passkey_approvals")
358
+ .where("deviceID", "=", deviceID)
359
+ .execute();
360
+ await trx
361
+ .updateTable("devices")
362
+ .set({ deleted: 1 })
363
+ .where("deviceID", "=", deviceID)
364
+ .execute();
365
+ });
348
366
  }
349
367
  async deleteEmoji(emojiID) {
350
368
  const existing = await this.retrieveEmoji(emojiID);
@@ -598,15 +616,17 @@ export class Database extends EventEmitter {
598
616
  * WebAuthn spec (FIDO authenticators that report 0 signal they
599
617
  * don't track a counter — callers should treat 0→0 as legitimate).
600
618
  */
601
- async markPasskeyUsed(passkeyID, signCount) {
602
- await this.db
619
+ async markPasskeyUsed(passkeyID, expectedSignCount, signCount) {
620
+ const result = await this.db
603
621
  .updateTable("passkeys")
604
622
  .set({
605
623
  lastUsedAt: new Date().toISOString(),
606
624
  signCount,
607
625
  })
608
626
  .where("passkeyID", "=", passkeyID)
609
- .execute();
627
+ .where("signCount", "=", expectedSignCount)
628
+ .executeTakeFirst();
629
+ return Number(result.numUpdatedRows) > 0;
610
630
  }
611
631
  async markUserSeen(user) {
612
632
  await this.db
@@ -761,9 +781,7 @@ export class Database extends EventEmitter {
761
781
  await this.db
762
782
  .updateTable("users")
763
783
  .set({
764
- hashAlgo: "argon2id",
765
784
  passwordHash: newHash,
766
- passwordSalt: "",
767
785
  })
768
786
  .where("userID", "=", userID)
769
787
  .execute();
@@ -1170,13 +1188,8 @@ export class Database extends EventEmitter {
1170
1188
  .executeTakeFirst();
1171
1189
  return row?.userID ?? null;
1172
1190
  }
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.
1191
+ // The identifier is matched as either a userID or the canonical lowercase
1192
+ // username stored by `normalizeRegistrationUsername`.
1180
1193
  async retrieveUser(userIdentifier) {
1181
1194
  let rows;
1182
1195
  if (uuidValidate(userIdentifier)) {
@@ -1188,11 +1201,11 @@ export class Database extends EventEmitter {
1188
1201
  .execute();
1189
1202
  }
1190
1203
  else {
1191
- const normalized = userIdentifier.toLowerCase();
1204
+ const normalized = userIdentifier.trim().toLowerCase();
1192
1205
  rows = await this.db
1193
1206
  .selectFrom("users")
1194
1207
  .selectAll()
1195
- .where(sql `lower(username)`, "=", normalized)
1208
+ .where("username", "=", normalized)
1196
1209
  .limit(1)
1197
1210
  .execute();
1198
1211
  }
@@ -1321,6 +1334,39 @@ export class Database extends EventEmitter {
1321
1334
  userID,
1322
1335
  });
1323
1336
  }
1337
+ async updateChannel(channelID, name) {
1338
+ const result = await this.db
1339
+ .updateTable("channels")
1340
+ .set({ name })
1341
+ .where("channelID", "=", channelID)
1342
+ .executeTakeFirst();
1343
+ if (Number(result.numUpdatedRows) === 0) {
1344
+ return null;
1345
+ }
1346
+ return this.retrieveChannel(channelID);
1347
+ }
1348
+ async updatePermissionPowerLevel(permissionID, powerLevel) {
1349
+ const result = await this.db
1350
+ .updateTable("permissions")
1351
+ .set({ powerLevel })
1352
+ .where("permissionID", "=", permissionID)
1353
+ .executeTakeFirst();
1354
+ if (Number(result.numUpdatedRows) === 0) {
1355
+ return null;
1356
+ }
1357
+ return this.retrievePermission(permissionID);
1358
+ }
1359
+ async updateServer(serverID, update) {
1360
+ const result = await this.db
1361
+ .updateTable("servers")
1362
+ .set(update)
1363
+ .where("serverID", "=", serverID)
1364
+ .executeTakeFirst();
1365
+ if (Number(result.numUpdatedRows) === 0) {
1366
+ return null;
1367
+ }
1368
+ return this.retrieveServer(serverID);
1369
+ }
1324
1370
  async upsertStoreSubscription(input) {
1325
1371
  const now = new Date().toISOString();
1326
1372
  const purchaseTokenHash = input.purchaseToken
@@ -1414,6 +1460,55 @@ export class Database extends EventEmitter {
1414
1460
  }
1415
1461
  this.emit("ready");
1416
1462
  }
1463
+ async insertDevice(trx, owner, payload, passkeyApproval) {
1464
+ const activeDeviceCount = await trx
1465
+ .selectFrom("devices")
1466
+ .select((eb) => eb.fn.countAll().as("count"))
1467
+ .where("owner", "=", owner)
1468
+ .where("deleted", "=", 0)
1469
+ .executeTakeFirst();
1470
+ if (Number(activeDeviceCount?.count ?? 0) >= MAX_ACTIVE_DEVICES_PER_USER) {
1471
+ throw new Error(`Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices.`);
1472
+ }
1473
+ const now = new Date().toISOString();
1474
+ const device = {
1475
+ deleted: 0,
1476
+ deviceID: crypto.randomUUID(),
1477
+ lastLogin: now,
1478
+ name: payload.deviceName,
1479
+ owner,
1480
+ signKey: payload.signKey,
1481
+ };
1482
+ const medPreKeys = {
1483
+ deviceID: device.deviceID,
1484
+ index: payload.preKeyIndex,
1485
+ keyID: crypto.randomUUID(),
1486
+ publicKey: payload.preKey,
1487
+ signature: payload.preKeySignature,
1488
+ userID: owner,
1489
+ };
1490
+ await trx.insertInto("devices").values(device).execute();
1491
+ await trx.insertInto("preKeys").values(medPreKeys).execute();
1492
+ if (passkeyApproval) {
1493
+ await trx
1494
+ .insertInto("device_passkey_approvals")
1495
+ .values({
1496
+ approvedAt: now,
1497
+ approvedByDeviceID: passkeyApproval.approvedByDeviceID ?? null,
1498
+ approvedByPasskeyID: passkeyApproval.approvedByPasskeyID,
1499
+ deviceID: device.deviceID,
1500
+ userID: owner,
1501
+ })
1502
+ .onConflict((oc) => oc.column("deviceID").doUpdateSet({
1503
+ approvedAt: now,
1504
+ approvedByDeviceID: passkeyApproval.approvedByDeviceID ?? null,
1505
+ approvedByPasskeyID: passkeyApproval.approvedByPasskeyID,
1506
+ userID: owner,
1507
+ }))
1508
+ .execute();
1509
+ }
1510
+ return toDevice(device);
1511
+ }
1417
1512
  mailSqlEntry(mail, header, deviceID, userID, time = new Date().toISOString()) {
1418
1513
  return {
1419
1514
  authorID: userID,
@@ -1438,36 +1533,57 @@ export class Database extends EventEmitter {
1438
1533
  * Returns the encoded hash string which embeds salt, params, and digest.
1439
1534
  */
1440
1535
  export async function hashPasswordArgon2(password) {
1441
- return argon2.hash(password, {
1442
- memoryCost: 65536,
1443
- parallelism: 4,
1444
- timeCost: 3,
1445
- type: argon2.argon2id,
1446
- });
1536
+ if (password.length === 0 ||
1537
+ password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
1538
+ throw new Error("Password length is outside the supported range.");
1539
+ }
1540
+ return argon2.hash(password, ARGON2_OPTIONS);
1541
+ }
1542
+ /**
1543
+ * Validate new account passwords without imposing composition rules. Passwords
1544
+ * are hashed exactly as supplied; normalization is used only for comparisons
1545
+ * against account-specific and common-password deny lists.
1546
+ */
1547
+ export function validateAccountPassword(password, username) {
1548
+ if (password.trim().length === 0 ||
1549
+ password.length < ACCOUNT_PASSWORD_MIN_LENGTH) {
1550
+ return `Password must be at least ${String(ACCOUNT_PASSWORD_MIN_LENGTH)} characters.`;
1551
+ }
1552
+ if (password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
1553
+ return `Password must be at most ${String(ACCOUNT_PASSWORD_MAX_LENGTH)} characters.`;
1554
+ }
1555
+ const comparable = password.normalize("NFKC").toLowerCase();
1556
+ const comparableUsername = username?.trim().normalize("NFKC").toLowerCase();
1557
+ const characters = Array.from(comparable);
1558
+ const repeatedSingleCharacter = characters.every((character) => character === characters[0]);
1559
+ if (COMMON_ACCOUNT_PASSWORDS.has(comparable) ||
1560
+ repeatedSingleCharacter ||
1561
+ (comparableUsername !== undefined && comparable === comparableUsername)) {
1562
+ return "Choose a less common password.";
1563
+ }
1564
+ return null;
1447
1565
  }
1448
1566
  /**
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.
1567
+ * Verify an Argon2id password hash. Unknown hash formats fail closed.
1452
1568
  */
1453
1569
  export async function verifyPassword(password, stored) {
1454
- if (stored.hashAlgo === "keycluster") {
1570
+ if (stored === null) {
1571
+ await argon2.verify(DUMMY_PASSWORD_HASH, password);
1455
1572
  return { needsRehash: false, valid: false };
1456
1573
  }
1457
- if (stored.hashAlgo === "argon2id") {
1574
+ try {
1458
1575
  const valid = await argon2.verify(stored.passwordHash, password);
1459
- return { needsRehash: false, valid };
1576
+ return {
1577
+ needsRehash: valid &&
1578
+ argon2.needsRehash(stored.passwordHash, ARGON2_OPTIONS),
1579
+ valid,
1580
+ };
1460
1581
  }
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) {
1582
+ catch {
1583
+ // Keep malformed rows on the same expensive path as a missing user.
1584
+ await argon2.verify(DUMMY_PASSWORD_HASH, password).catch(() => false);
1466
1585
  return { needsRehash: false, valid: false };
1467
1586
  }
1468
- const { timingSafeEqual } = await import("node:crypto");
1469
- const valid = timingSafeEqual(computed, storedBuf);
1470
- return { needsRehash: valid, valid };
1471
1587
  }
1472
1588
  function accountTierRank(tier) {
1473
1589
  switch (tier) {
@@ -1530,13 +1646,8 @@ function expiryRank(expiresAt) {
1530
1646
  // flows) gets the same lowercase canonicalization the public
1531
1647
  // `POST /register` route applies. Usernames are case-insensitive at
1532
1648
  // 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}`;
1649
+ function normalizeRegistrationUsername(providedUsername) {
1650
+ return providedUsername.trim().toLowerCase();
1540
1651
  }
1541
1652
  function parseAccountEntitlementSource(source) {
1542
1653
  const parsed = AccountEntitlementSourceSchema.safeParse(source);