@vex-chat/spire 3.0.1 → 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.
- package/README.md +9 -8
- package/dist/Database.d.ts +12 -11
- package/dist/Database.js +161 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -2
- package/dist/Spire.js +154 -71
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +0 -2
- package/dist/migrations/2026-04-06_initial-schema.js +4 -5
- package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
- package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
- package/dist/migrations/2026-07-14_query-indexes.js +31 -0
- package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
- package/dist/server/avatar.js +30 -4
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/errors.js +8 -0
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.js +35 -12
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.js +157 -41
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +41 -26
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.js +1 -0
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/password.d.ts +7 -0
- package/dist/server/password.js +58 -0
- package/dist/server/password.js.map +1 -0
- package/dist/server/permissions.d.ts +1 -0
- package/dist/server/permissions.js +11 -2
- package/dist/server/permissions.js.map +1 -1
- package/dist/server/rateLimit.d.ts +11 -0
- package/dist/server/rateLimit.js +43 -4
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +1 -1
- package/dist/server/user.js +40 -9
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +3 -4
- package/dist/types/express.js.map +1 -1
- package/dist/utils/authJwt.d.ts +8 -0
- package/dist/utils/authJwt.js +25 -0
- package/dist/utils/authJwt.js.map +1 -0
- package/dist/utils/loadEnv.js +4 -0
- package/dist/utils/loadEnv.js.map +1 -1
- package/dist/utils/preKeySignature.d.ts +1 -1
- package/dist/utils/preKeySignature.js +7 -11
- package/dist/utils/preKeySignature.js.map +1 -1
- package/package.json +7 -7
- package/src/Database.ts +211 -152
- package/src/Spire.ts +411 -284
- package/src/__tests__/Database.spec.ts +126 -3
- package/src/__tests__/connectAuth.spec.ts +146 -4
- package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
- package/src/__tests__/passkeyDevices.spec.ts +94 -0
- package/src/__tests__/passkeys.spec.ts +7 -2
- package/src/__tests__/password.spec.ts +223 -0
- package/src/__tests__/permissions.spec.ts +50 -0
- package/src/__tests__/preKeySignature.spec.ts +20 -3
- package/src/db/schema.ts +0 -2
- package/src/migrations/2026-04-06_initial-schema.ts +4 -5
- package/src/migrations/2026-07-14_query-indexes.ts +34 -0
- package/src/server/avatar.ts +29 -4
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +286 -111
- package/src/server/passkey.ts +51 -28
- package/src/server/passkeyDevices.ts +1 -0
- package/src/server/password.ts +96 -0
- package/src/server/permissions.ts +20 -2
- package/src/server/rateLimit.ts +47 -4
- package/src/server/user.ts +44 -9
- package/src/types/express.ts +3 -4
- package/src/utils/authJwt.ts +34 -0
- package/src/utils/loadEnv.ts +4 -0
- package/src/utils/preKeySignature.ts +12 -15
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
- 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
|
|
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 =
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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) {
|
|
@@ -504,31 +479,33 @@ export class Database extends EventEmitter {
|
|
|
504
479
|
}
|
|
505
480
|
|
|
506
481
|
public async deleteDevice(deviceID: string): Promise<void> {
|
|
507
|
-
await this.db
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
482
|
+
await this.db.transaction().execute(async (trx) => {
|
|
483
|
+
await trx
|
|
484
|
+
.deleteFrom("preKeys")
|
|
485
|
+
.where("deviceID", "=", deviceID)
|
|
486
|
+
.execute();
|
|
511
487
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
488
|
+
await trx
|
|
489
|
+
.deleteFrom("oneTimeKeys")
|
|
490
|
+
.where("deviceID", "=", deviceID)
|
|
491
|
+
.execute();
|
|
516
492
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
493
|
+
await trx
|
|
494
|
+
.deleteFrom("notification_subscriptions")
|
|
495
|
+
.where("deviceID", "=", deviceID)
|
|
496
|
+
.execute();
|
|
521
497
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
498
|
+
await trx
|
|
499
|
+
.deleteFrom("device_passkey_approvals")
|
|
500
|
+
.where("deviceID", "=", deviceID)
|
|
501
|
+
.execute();
|
|
526
502
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
503
|
+
await trx
|
|
504
|
+
.updateTable("devices")
|
|
505
|
+
.set({ deleted: 1 })
|
|
506
|
+
.where("deviceID", "=", deviceID)
|
|
507
|
+
.execute();
|
|
508
|
+
});
|
|
532
509
|
}
|
|
533
510
|
|
|
534
511
|
public async deleteEmoji(emojiID: string): Promise<void> {
|
|
@@ -815,16 +792,19 @@ export class Database extends EventEmitter {
|
|
|
815
792
|
*/
|
|
816
793
|
public async markPasskeyUsed(
|
|
817
794
|
passkeyID: string,
|
|
795
|
+
expectedSignCount: number,
|
|
818
796
|
signCount: number,
|
|
819
|
-
): Promise<
|
|
820
|
-
await this.db
|
|
797
|
+
): Promise<boolean> {
|
|
798
|
+
const result = await this.db
|
|
821
799
|
.updateTable("passkeys")
|
|
822
800
|
.set({
|
|
823
801
|
lastUsedAt: new Date().toISOString(),
|
|
824
802
|
signCount,
|
|
825
803
|
})
|
|
826
804
|
.where("passkeyID", "=", passkeyID)
|
|
827
|
-
.
|
|
805
|
+
.where("signCount", "=", expectedSignCount)
|
|
806
|
+
.executeTakeFirst();
|
|
807
|
+
return Number(result.numUpdatedRows) > 0;
|
|
828
808
|
}
|
|
829
809
|
|
|
830
810
|
public async markUserSeen(user: UserRecord): Promise<void> {
|
|
@@ -1013,9 +993,7 @@ export class Database extends EventEmitter {
|
|
|
1013
993
|
await this.db
|
|
1014
994
|
.updateTable("users")
|
|
1015
995
|
.set({
|
|
1016
|
-
hashAlgo: "argon2id",
|
|
1017
996
|
passwordHash: newHash,
|
|
1018
|
-
passwordSalt: "",
|
|
1019
997
|
})
|
|
1020
998
|
.where("userID", "=", userID)
|
|
1021
999
|
.execute();
|
|
@@ -1526,13 +1504,8 @@ export class Database extends EventEmitter {
|
|
|
1526
1504
|
return row?.userID ?? null;
|
|
1527
1505
|
}
|
|
1528
1506
|
|
|
1529
|
-
// The identifier is matched as either a userID
|
|
1530
|
-
// username
|
|
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.
|
|
1507
|
+
// The identifier is matched as either a userID or the canonical lowercase
|
|
1508
|
+
// username stored by `normalizeRegistrationUsername`.
|
|
1536
1509
|
public async retrieveUser(
|
|
1537
1510
|
userIdentifier: string,
|
|
1538
1511
|
): Promise<InternalUserRecord | null> {
|
|
@@ -1545,11 +1518,11 @@ export class Database extends EventEmitter {
|
|
|
1545
1518
|
.limit(1)
|
|
1546
1519
|
.execute();
|
|
1547
1520
|
} else {
|
|
1548
|
-
const normalized = userIdentifier.toLowerCase();
|
|
1521
|
+
const normalized = userIdentifier.trim().toLowerCase();
|
|
1549
1522
|
rows = await this.db
|
|
1550
1523
|
.selectFrom("users")
|
|
1551
1524
|
.selectAll()
|
|
1552
|
-
.where(
|
|
1525
|
+
.where("username", "=", normalized)
|
|
1553
1526
|
.limit(1)
|
|
1554
1527
|
.execute();
|
|
1555
1528
|
}
|
|
@@ -1842,6 +1815,74 @@ export class Database extends EventEmitter {
|
|
|
1842
1815
|
this.emit("ready");
|
|
1843
1816
|
}
|
|
1844
1817
|
|
|
1818
|
+
private async insertDevice(
|
|
1819
|
+
trx: Transaction<ServerDatabase>,
|
|
1820
|
+
owner: string,
|
|
1821
|
+
payload: DevicePayload,
|
|
1822
|
+
passkeyApproval?: DevicePasskeyApprovalInput,
|
|
1823
|
+
): Promise<Device> {
|
|
1824
|
+
const activeDeviceCount = await trx
|
|
1825
|
+
.selectFrom("devices")
|
|
1826
|
+
.select((eb) => eb.fn.countAll().as("count"))
|
|
1827
|
+
.where("owner", "=", owner)
|
|
1828
|
+
.where("deleted", "=", 0)
|
|
1829
|
+
.executeTakeFirst();
|
|
1830
|
+
if (
|
|
1831
|
+
Number(activeDeviceCount?.count ?? 0) >= MAX_ACTIVE_DEVICES_PER_USER
|
|
1832
|
+
) {
|
|
1833
|
+
throw new Error(
|
|
1834
|
+
`Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices.`,
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
const now = new Date().toISOString();
|
|
1839
|
+
const device = {
|
|
1840
|
+
deleted: 0,
|
|
1841
|
+
deviceID: crypto.randomUUID(),
|
|
1842
|
+
lastLogin: now,
|
|
1843
|
+
name: payload.deviceName,
|
|
1844
|
+
owner,
|
|
1845
|
+
signKey: payload.signKey,
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
const medPreKeys = {
|
|
1849
|
+
deviceID: device.deviceID,
|
|
1850
|
+
index: payload.preKeyIndex,
|
|
1851
|
+
keyID: crypto.randomUUID(),
|
|
1852
|
+
publicKey: payload.preKey,
|
|
1853
|
+
signature: payload.preKeySignature,
|
|
1854
|
+
userID: owner,
|
|
1855
|
+
};
|
|
1856
|
+
|
|
1857
|
+
await trx.insertInto("devices").values(device).execute();
|
|
1858
|
+
await trx.insertInto("preKeys").values(medPreKeys).execute();
|
|
1859
|
+
if (passkeyApproval) {
|
|
1860
|
+
await trx
|
|
1861
|
+
.insertInto("device_passkey_approvals")
|
|
1862
|
+
.values({
|
|
1863
|
+
approvedAt: now,
|
|
1864
|
+
approvedByDeviceID:
|
|
1865
|
+
passkeyApproval.approvedByDeviceID ?? null,
|
|
1866
|
+
approvedByPasskeyID: passkeyApproval.approvedByPasskeyID,
|
|
1867
|
+
deviceID: device.deviceID,
|
|
1868
|
+
userID: owner,
|
|
1869
|
+
})
|
|
1870
|
+
.onConflict((oc) =>
|
|
1871
|
+
oc.column("deviceID").doUpdateSet({
|
|
1872
|
+
approvedAt: now,
|
|
1873
|
+
approvedByDeviceID:
|
|
1874
|
+
passkeyApproval.approvedByDeviceID ?? null,
|
|
1875
|
+
approvedByPasskeyID:
|
|
1876
|
+
passkeyApproval.approvedByPasskeyID,
|
|
1877
|
+
userID: owner,
|
|
1878
|
+
}),
|
|
1879
|
+
)
|
|
1880
|
+
.execute();
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
return toDevice(device);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1845
1886
|
private mailSqlEntry(
|
|
1846
1887
|
mail: MailWS,
|
|
1847
1888
|
header: Uint8Array,
|
|
@@ -1873,49 +1914,77 @@ export class Database extends EventEmitter {
|
|
|
1873
1914
|
* Returns the encoded hash string which embeds salt, params, and digest.
|
|
1874
1915
|
*/
|
|
1875
1916
|
export async function hashPasswordArgon2(password: string): Promise<string> {
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
}
|
|
1917
|
+
if (
|
|
1918
|
+
password.length === 0 ||
|
|
1919
|
+
password.length > ACCOUNT_PASSWORD_MAX_LENGTH
|
|
1920
|
+
) {
|
|
1921
|
+
throw new Error("Password length is outside the supported range.");
|
|
1922
|
+
}
|
|
1923
|
+
return argon2.hash(password, ARGON2_OPTIONS);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Validate new account passwords without imposing composition rules. Passwords
|
|
1928
|
+
* are hashed exactly as supplied; normalization is used only for comparisons
|
|
1929
|
+
* against account-specific and common-password deny lists.
|
|
1930
|
+
*/
|
|
1931
|
+
export function validateAccountPassword(
|
|
1932
|
+
password: string,
|
|
1933
|
+
username?: string,
|
|
1934
|
+
): null | string {
|
|
1935
|
+
if (
|
|
1936
|
+
password.trim().length === 0 ||
|
|
1937
|
+
password.length < ACCOUNT_PASSWORD_MIN_LENGTH
|
|
1938
|
+
) {
|
|
1939
|
+
return `Password must be at least ${String(ACCOUNT_PASSWORD_MIN_LENGTH)} characters.`;
|
|
1940
|
+
}
|
|
1941
|
+
if (password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
|
|
1942
|
+
return `Password must be at most ${String(ACCOUNT_PASSWORD_MAX_LENGTH)} characters.`;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
const comparable = password.normalize("NFKC").toLowerCase();
|
|
1946
|
+
const comparableUsername = username?.trim().normalize("NFKC").toLowerCase();
|
|
1947
|
+
const characters = Array.from(comparable);
|
|
1948
|
+
const repeatedSingleCharacter = characters.every(
|
|
1949
|
+
(character) => character === characters[0],
|
|
1950
|
+
);
|
|
1951
|
+
if (
|
|
1952
|
+
COMMON_ACCOUNT_PASSWORDS.has(comparable) ||
|
|
1953
|
+
repeatedSingleCharacter ||
|
|
1954
|
+
(comparableUsername !== undefined && comparable === comparableUsername)
|
|
1955
|
+
) {
|
|
1956
|
+
return "Choose a less common password.";
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
return null;
|
|
1882
1960
|
}
|
|
1883
1961
|
|
|
1884
1962
|
/**
|
|
1885
|
-
* Verify
|
|
1886
|
-
* Returns `{ valid, needsRehash }` — callers should rehash on success
|
|
1887
|
-
* when `needsRehash` is true.
|
|
1963
|
+
* Verify an Argon2id password hash. Unknown hash formats fail closed.
|
|
1888
1964
|
*/
|
|
1889
1965
|
export async function verifyPassword(
|
|
1890
1966
|
password: string,
|
|
1891
|
-
stored:
|
|
1967
|
+
stored: null | {
|
|
1968
|
+
passwordHash: string;
|
|
1969
|
+
},
|
|
1892
1970
|
): Promise<{ needsRehash: boolean; valid: boolean }> {
|
|
1893
|
-
if (stored
|
|
1971
|
+
if (stored === null) {
|
|
1972
|
+
await argon2.verify(DUMMY_PASSWORD_HASH, password);
|
|
1894
1973
|
return { needsRehash: false, valid: false };
|
|
1895
1974
|
}
|
|
1896
|
-
|
|
1975
|
+
try {
|
|
1897
1976
|
const valid = await argon2.verify(stored.passwordHash, password);
|
|
1898
|
-
return {
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
32,
|
|
1908
|
-
"sha512",
|
|
1909
|
-
);
|
|
1910
|
-
const storedBuf = XUtils.decodeHex(stored.passwordHash);
|
|
1911
|
-
|
|
1912
|
-
if (computed.length !== storedBuf.length) {
|
|
1977
|
+
return {
|
|
1978
|
+
needsRehash:
|
|
1979
|
+
valid &&
|
|
1980
|
+
argon2.needsRehash(stored.passwordHash, ARGON2_OPTIONS),
|
|
1981
|
+
valid,
|
|
1982
|
+
};
|
|
1983
|
+
} catch {
|
|
1984
|
+
// Keep malformed rows on the same expensive path as a missing user.
|
|
1985
|
+
await argon2.verify(DUMMY_PASSWORD_HASH, password).catch(() => false);
|
|
1913
1986
|
return { needsRehash: false, valid: false };
|
|
1914
1987
|
}
|
|
1915
|
-
|
|
1916
|
-
const { timingSafeEqual } = await import("node:crypto");
|
|
1917
|
-
const valid = timingSafeEqual(computed, storedBuf);
|
|
1918
|
-
return { needsRehash: valid, valid };
|
|
1919
1988
|
}
|
|
1920
1989
|
|
|
1921
1990
|
function accountTierRank(tier: AccountTier): number {
|
|
@@ -2001,16 +2070,8 @@ function expiryRank(expiresAt: null | string): number {
|
|
|
2001
2070
|
// flows) gets the same lowercase canonicalization the public
|
|
2002
2071
|
// `POST /register` route applies. Usernames are case-insensitive at
|
|
2003
2072
|
// the protocol level.
|
|
2004
|
-
function normalizeRegistrationUsername(
|
|
2005
|
-
providedUsername
|
|
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}`;
|
|
2073
|
+
function normalizeRegistrationUsername(providedUsername: string): string {
|
|
2074
|
+
return providedUsername.trim().toLowerCase();
|
|
2014
2075
|
}
|
|
2015
2076
|
|
|
2016
2077
|
function parseAccountEntitlementSource(
|
|
@@ -2131,10 +2192,8 @@ function toServer(row: {
|
|
|
2131
2192
|
}
|
|
2132
2193
|
|
|
2133
2194
|
function toUserRecord(row: {
|
|
2134
|
-
hashAlgo: string;
|
|
2135
2195
|
lastSeen: string;
|
|
2136
2196
|
passwordHash: string;
|
|
2137
|
-
passwordSalt: string;
|
|
2138
2197
|
userID: string;
|
|
2139
2198
|
username: string;
|
|
2140
2199
|
}): InternalUserRecord {
|