@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.
- package/README.md +9 -8
- package/dist/Database.d.ts +20 -11
- package/dist/Database.js +229 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -2
- package/dist/Spire.js +155 -72
- 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 +33 -6
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +109 -11
- package/dist/server/cliPasskeyPage.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 +319 -56
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +367 -29
- 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/serverIcon.d.ts +10 -0
- package/dist/server/serverIcon.js +158 -0
- package/dist/server/serverIcon.js.map +1 -0
- 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 +294 -152
- package/src/Spire.ts +412 -285
- package/src/__tests__/Database.spec.ts +126 -3
- package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
- package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
- 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/__tests__/serverManagement.spec.ts +262 -0
- 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 +32 -6
- package/src/server/cliPasskeyPage.ts +109 -11
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +494 -139
- package/src/server/passkey.ts +531 -31
- 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/serverIcon.ts +199 -0
- 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) {
|
|
@@ -503,32 +478,72 @@ export class Database extends EventEmitter {
|
|
|
503
478
|
.execute();
|
|
504
479
|
}
|
|
505
480
|
|
|
481
|
+
/** Delete a channel while preserving the invariant that a server has one. */
|
|
482
|
+
public async deleteChannelIfNotLast(channelID: string): Promise<boolean> {
|
|
483
|
+
return this.db.transaction().execute(async (trx) => {
|
|
484
|
+
const channel = await trx
|
|
485
|
+
.selectFrom("channels")
|
|
486
|
+
.selectAll()
|
|
487
|
+
.where("channelID", "=", channelID)
|
|
488
|
+
.executeTakeFirst();
|
|
489
|
+
if (!channel) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const siblings = await trx
|
|
494
|
+
.selectFrom("channels")
|
|
495
|
+
.select("channelID")
|
|
496
|
+
.where("serverID", "=", channel.serverID)
|
|
497
|
+
.limit(2)
|
|
498
|
+
.execute();
|
|
499
|
+
if (siblings.length <= 1) {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
await trx
|
|
504
|
+
.deleteFrom("permissions")
|
|
505
|
+
.where("resourceID", "=", channelID)
|
|
506
|
+
.execute();
|
|
507
|
+
await trx
|
|
508
|
+
.deleteFrom("mail")
|
|
509
|
+
.where("group", "=", channelID)
|
|
510
|
+
.execute();
|
|
511
|
+
await trx
|
|
512
|
+
.deleteFrom("channels")
|
|
513
|
+
.where("channelID", "=", channelID)
|
|
514
|
+
.execute();
|
|
515
|
+
return true;
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
506
519
|
public async deleteDevice(deviceID: string): Promise<void> {
|
|
507
|
-
await this.db
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
520
|
+
await this.db.transaction().execute(async (trx) => {
|
|
521
|
+
await trx
|
|
522
|
+
.deleteFrom("preKeys")
|
|
523
|
+
.where("deviceID", "=", deviceID)
|
|
524
|
+
.execute();
|
|
511
525
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
526
|
+
await trx
|
|
527
|
+
.deleteFrom("oneTimeKeys")
|
|
528
|
+
.where("deviceID", "=", deviceID)
|
|
529
|
+
.execute();
|
|
516
530
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
531
|
+
await trx
|
|
532
|
+
.deleteFrom("notification_subscriptions")
|
|
533
|
+
.where("deviceID", "=", deviceID)
|
|
534
|
+
.execute();
|
|
521
535
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
536
|
+
await trx
|
|
537
|
+
.deleteFrom("device_passkey_approvals")
|
|
538
|
+
.where("deviceID", "=", deviceID)
|
|
539
|
+
.execute();
|
|
526
540
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
541
|
+
await trx
|
|
542
|
+
.updateTable("devices")
|
|
543
|
+
.set({ deleted: 1 })
|
|
544
|
+
.where("deviceID", "=", deviceID)
|
|
545
|
+
.execute();
|
|
546
|
+
});
|
|
532
547
|
}
|
|
533
548
|
|
|
534
549
|
public async deleteEmoji(emojiID: string): Promise<void> {
|
|
@@ -815,16 +830,19 @@ export class Database extends EventEmitter {
|
|
|
815
830
|
*/
|
|
816
831
|
public async markPasskeyUsed(
|
|
817
832
|
passkeyID: string,
|
|
833
|
+
expectedSignCount: number,
|
|
818
834
|
signCount: number,
|
|
819
|
-
): Promise<
|
|
820
|
-
await this.db
|
|
835
|
+
): Promise<boolean> {
|
|
836
|
+
const result = await this.db
|
|
821
837
|
.updateTable("passkeys")
|
|
822
838
|
.set({
|
|
823
839
|
lastUsedAt: new Date().toISOString(),
|
|
824
840
|
signCount,
|
|
825
841
|
})
|
|
826
842
|
.where("passkeyID", "=", passkeyID)
|
|
827
|
-
.
|
|
843
|
+
.where("signCount", "=", expectedSignCount)
|
|
844
|
+
.executeTakeFirst();
|
|
845
|
+
return Number(result.numUpdatedRows) > 0;
|
|
828
846
|
}
|
|
829
847
|
|
|
830
848
|
public async markUserSeen(user: UserRecord): Promise<void> {
|
|
@@ -1013,9 +1031,7 @@ export class Database extends EventEmitter {
|
|
|
1013
1031
|
await this.db
|
|
1014
1032
|
.updateTable("users")
|
|
1015
1033
|
.set({
|
|
1016
|
-
hashAlgo: "argon2id",
|
|
1017
1034
|
passwordHash: newHash,
|
|
1018
|
-
passwordSalt: "",
|
|
1019
1035
|
})
|
|
1020
1036
|
.where("userID", "=", userID)
|
|
1021
1037
|
.execute();
|
|
@@ -1526,13 +1542,8 @@ export class Database extends EventEmitter {
|
|
|
1526
1542
|
return row?.userID ?? null;
|
|
1527
1543
|
}
|
|
1528
1544
|
|
|
1529
|
-
// The identifier is matched as either a userID
|
|
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.
|
|
1545
|
+
// The identifier is matched as either a userID or the canonical lowercase
|
|
1546
|
+
// username stored by `normalizeRegistrationUsername`.
|
|
1536
1547
|
public async retrieveUser(
|
|
1537
1548
|
userIdentifier: string,
|
|
1538
1549
|
): Promise<InternalUserRecord | null> {
|
|
@@ -1545,11 +1556,11 @@ export class Database extends EventEmitter {
|
|
|
1545
1556
|
.limit(1)
|
|
1546
1557
|
.execute();
|
|
1547
1558
|
} else {
|
|
1548
|
-
const normalized = userIdentifier.toLowerCase();
|
|
1559
|
+
const normalized = userIdentifier.trim().toLowerCase();
|
|
1549
1560
|
rows = await this.db
|
|
1550
1561
|
.selectFrom("users")
|
|
1551
1562
|
.selectAll()
|
|
1552
|
-
.where(
|
|
1563
|
+
.where("username", "=", normalized)
|
|
1553
1564
|
.limit(1)
|
|
1554
1565
|
.execute();
|
|
1555
1566
|
}
|
|
@@ -1731,6 +1742,51 @@ export class Database extends EventEmitter {
|
|
|
1731
1742
|
});
|
|
1732
1743
|
}
|
|
1733
1744
|
|
|
1745
|
+
public async updateChannel(
|
|
1746
|
+
channelID: string,
|
|
1747
|
+
name: string,
|
|
1748
|
+
): Promise<Channel | null> {
|
|
1749
|
+
const result = await this.db
|
|
1750
|
+
.updateTable("channels")
|
|
1751
|
+
.set({ name })
|
|
1752
|
+
.where("channelID", "=", channelID)
|
|
1753
|
+
.executeTakeFirst();
|
|
1754
|
+
if (Number(result.numUpdatedRows) === 0) {
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
return this.retrieveChannel(channelID);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
public async updatePermissionPowerLevel(
|
|
1761
|
+
permissionID: string,
|
|
1762
|
+
powerLevel: number,
|
|
1763
|
+
): Promise<null | Permission> {
|
|
1764
|
+
const result = await this.db
|
|
1765
|
+
.updateTable("permissions")
|
|
1766
|
+
.set({ powerLevel })
|
|
1767
|
+
.where("permissionID", "=", permissionID)
|
|
1768
|
+
.executeTakeFirst();
|
|
1769
|
+
if (Number(result.numUpdatedRows) === 0) {
|
|
1770
|
+
return null;
|
|
1771
|
+
}
|
|
1772
|
+
return this.retrievePermission(permissionID);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
public async updateServer(
|
|
1776
|
+
serverID: string,
|
|
1777
|
+
update: { icon?: null | string; name?: string },
|
|
1778
|
+
): Promise<null | Server> {
|
|
1779
|
+
const result = await this.db
|
|
1780
|
+
.updateTable("servers")
|
|
1781
|
+
.set(update)
|
|
1782
|
+
.where("serverID", "=", serverID)
|
|
1783
|
+
.executeTakeFirst();
|
|
1784
|
+
if (Number(result.numUpdatedRows) === 0) {
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1787
|
+
return this.retrieveServer(serverID);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1734
1790
|
public async upsertStoreSubscription(
|
|
1735
1791
|
input: StoreSubscriptionUpsertInput,
|
|
1736
1792
|
): Promise<BillingSubscription> {
|
|
@@ -1842,6 +1898,74 @@ export class Database extends EventEmitter {
|
|
|
1842
1898
|
this.emit("ready");
|
|
1843
1899
|
}
|
|
1844
1900
|
|
|
1901
|
+
private async insertDevice(
|
|
1902
|
+
trx: Transaction<ServerDatabase>,
|
|
1903
|
+
owner: string,
|
|
1904
|
+
payload: DevicePayload,
|
|
1905
|
+
passkeyApproval?: DevicePasskeyApprovalInput,
|
|
1906
|
+
): Promise<Device> {
|
|
1907
|
+
const activeDeviceCount = await trx
|
|
1908
|
+
.selectFrom("devices")
|
|
1909
|
+
.select((eb) => eb.fn.countAll().as("count"))
|
|
1910
|
+
.where("owner", "=", owner)
|
|
1911
|
+
.where("deleted", "=", 0)
|
|
1912
|
+
.executeTakeFirst();
|
|
1913
|
+
if (
|
|
1914
|
+
Number(activeDeviceCount?.count ?? 0) >= MAX_ACTIVE_DEVICES_PER_USER
|
|
1915
|
+
) {
|
|
1916
|
+
throw new Error(
|
|
1917
|
+
`Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices.`,
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
const now = new Date().toISOString();
|
|
1922
|
+
const device = {
|
|
1923
|
+
deleted: 0,
|
|
1924
|
+
deviceID: crypto.randomUUID(),
|
|
1925
|
+
lastLogin: now,
|
|
1926
|
+
name: payload.deviceName,
|
|
1927
|
+
owner,
|
|
1928
|
+
signKey: payload.signKey,
|
|
1929
|
+
};
|
|
1930
|
+
|
|
1931
|
+
const medPreKeys = {
|
|
1932
|
+
deviceID: device.deviceID,
|
|
1933
|
+
index: payload.preKeyIndex,
|
|
1934
|
+
keyID: crypto.randomUUID(),
|
|
1935
|
+
publicKey: payload.preKey,
|
|
1936
|
+
signature: payload.preKeySignature,
|
|
1937
|
+
userID: owner,
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
await trx.insertInto("devices").values(device).execute();
|
|
1941
|
+
await trx.insertInto("preKeys").values(medPreKeys).execute();
|
|
1942
|
+
if (passkeyApproval) {
|
|
1943
|
+
await trx
|
|
1944
|
+
.insertInto("device_passkey_approvals")
|
|
1945
|
+
.values({
|
|
1946
|
+
approvedAt: now,
|
|
1947
|
+
approvedByDeviceID:
|
|
1948
|
+
passkeyApproval.approvedByDeviceID ?? null,
|
|
1949
|
+
approvedByPasskeyID: passkeyApproval.approvedByPasskeyID,
|
|
1950
|
+
deviceID: device.deviceID,
|
|
1951
|
+
userID: owner,
|
|
1952
|
+
})
|
|
1953
|
+
.onConflict((oc) =>
|
|
1954
|
+
oc.column("deviceID").doUpdateSet({
|
|
1955
|
+
approvedAt: now,
|
|
1956
|
+
approvedByDeviceID:
|
|
1957
|
+
passkeyApproval.approvedByDeviceID ?? null,
|
|
1958
|
+
approvedByPasskeyID:
|
|
1959
|
+
passkeyApproval.approvedByPasskeyID,
|
|
1960
|
+
userID: owner,
|
|
1961
|
+
}),
|
|
1962
|
+
)
|
|
1963
|
+
.execute();
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
return toDevice(device);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1845
1969
|
private mailSqlEntry(
|
|
1846
1970
|
mail: MailWS,
|
|
1847
1971
|
header: Uint8Array,
|
|
@@ -1873,49 +1997,77 @@ export class Database extends EventEmitter {
|
|
|
1873
1997
|
* Returns the encoded hash string which embeds salt, params, and digest.
|
|
1874
1998
|
*/
|
|
1875
1999
|
export async function hashPasswordArgon2(password: string): Promise<string> {
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
}
|
|
2000
|
+
if (
|
|
2001
|
+
password.length === 0 ||
|
|
2002
|
+
password.length > ACCOUNT_PASSWORD_MAX_LENGTH
|
|
2003
|
+
) {
|
|
2004
|
+
throw new Error("Password length is outside the supported range.");
|
|
2005
|
+
}
|
|
2006
|
+
return argon2.hash(password, ARGON2_OPTIONS);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Validate new account passwords without imposing composition rules. Passwords
|
|
2011
|
+
* are hashed exactly as supplied; normalization is used only for comparisons
|
|
2012
|
+
* against account-specific and common-password deny lists.
|
|
2013
|
+
*/
|
|
2014
|
+
export function validateAccountPassword(
|
|
2015
|
+
password: string,
|
|
2016
|
+
username?: string,
|
|
2017
|
+
): null | string {
|
|
2018
|
+
if (
|
|
2019
|
+
password.trim().length === 0 ||
|
|
2020
|
+
password.length < ACCOUNT_PASSWORD_MIN_LENGTH
|
|
2021
|
+
) {
|
|
2022
|
+
return `Password must be at least ${String(ACCOUNT_PASSWORD_MIN_LENGTH)} characters.`;
|
|
2023
|
+
}
|
|
2024
|
+
if (password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
|
|
2025
|
+
return `Password must be at most ${String(ACCOUNT_PASSWORD_MAX_LENGTH)} characters.`;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
const comparable = password.normalize("NFKC").toLowerCase();
|
|
2029
|
+
const comparableUsername = username?.trim().normalize("NFKC").toLowerCase();
|
|
2030
|
+
const characters = Array.from(comparable);
|
|
2031
|
+
const repeatedSingleCharacter = characters.every(
|
|
2032
|
+
(character) => character === characters[0],
|
|
2033
|
+
);
|
|
2034
|
+
if (
|
|
2035
|
+
COMMON_ACCOUNT_PASSWORDS.has(comparable) ||
|
|
2036
|
+
repeatedSingleCharacter ||
|
|
2037
|
+
(comparableUsername !== undefined && comparable === comparableUsername)
|
|
2038
|
+
) {
|
|
2039
|
+
return "Choose a less common password.";
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
return null;
|
|
1882
2043
|
}
|
|
1883
2044
|
|
|
1884
2045
|
/**
|
|
1885
|
-
* Verify
|
|
1886
|
-
* Returns `{ valid, needsRehash }` — callers should rehash on success
|
|
1887
|
-
* when `needsRehash` is true.
|
|
2046
|
+
* Verify an Argon2id password hash. Unknown hash formats fail closed.
|
|
1888
2047
|
*/
|
|
1889
2048
|
export async function verifyPassword(
|
|
1890
2049
|
password: string,
|
|
1891
|
-
stored:
|
|
2050
|
+
stored: null | {
|
|
2051
|
+
passwordHash: string;
|
|
2052
|
+
},
|
|
1892
2053
|
): Promise<{ needsRehash: boolean; valid: boolean }> {
|
|
1893
|
-
if (stored
|
|
2054
|
+
if (stored === null) {
|
|
2055
|
+
await argon2.verify(DUMMY_PASSWORD_HASH, password);
|
|
1894
2056
|
return { needsRehash: false, valid: false };
|
|
1895
2057
|
}
|
|
1896
|
-
|
|
2058
|
+
try {
|
|
1897
2059
|
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) {
|
|
2060
|
+
return {
|
|
2061
|
+
needsRehash:
|
|
2062
|
+
valid &&
|
|
2063
|
+
argon2.needsRehash(stored.passwordHash, ARGON2_OPTIONS),
|
|
2064
|
+
valid,
|
|
2065
|
+
};
|
|
2066
|
+
} catch {
|
|
2067
|
+
// Keep malformed rows on the same expensive path as a missing user.
|
|
2068
|
+
await argon2.verify(DUMMY_PASSWORD_HASH, password).catch(() => false);
|
|
1913
2069
|
return { needsRehash: false, valid: false };
|
|
1914
2070
|
}
|
|
1915
|
-
|
|
1916
|
-
const { timingSafeEqual } = await import("node:crypto");
|
|
1917
|
-
const valid = timingSafeEqual(computed, storedBuf);
|
|
1918
|
-
return { needsRehash: valid, valid };
|
|
1919
2071
|
}
|
|
1920
2072
|
|
|
1921
2073
|
function accountTierRank(tier: AccountTier): number {
|
|
@@ -2001,16 +2153,8 @@ function expiryRank(expiresAt: null | string): number {
|
|
|
2001
2153
|
// flows) gets the same lowercase canonicalization the public
|
|
2002
2154
|
// `POST /register` route applies. Usernames are case-insensitive at
|
|
2003
2155
|
// the protocol level.
|
|
2004
|
-
function normalizeRegistrationUsername(
|
|
2005
|
-
providedUsername
|
|
2006
|
-
userID: string,
|
|
2007
|
-
): string {
|
|
2008
|
-
const trimmed = providedUsername?.trim().toLowerCase();
|
|
2009
|
-
if (trimmed && trimmed.length > 0) {
|
|
2010
|
-
return trimmed;
|
|
2011
|
-
}
|
|
2012
|
-
const seed = userID.replaceAll("-", "").slice(0, 12);
|
|
2013
|
-
return `key_${seed}`;
|
|
2156
|
+
function normalizeRegistrationUsername(providedUsername: string): string {
|
|
2157
|
+
return providedUsername.trim().toLowerCase();
|
|
2014
2158
|
}
|
|
2015
2159
|
|
|
2016
2160
|
function parseAccountEntitlementSource(
|
|
@@ -2131,10 +2275,8 @@ function toServer(row: {
|
|
|
2131
2275
|
}
|
|
2132
2276
|
|
|
2133
2277
|
function toUserRecord(row: {
|
|
2134
|
-
hashAlgo: string;
|
|
2135
2278
|
lastSeen: string;
|
|
2136
2279
|
passwordHash: string;
|
|
2137
|
-
passwordSalt: string;
|
|
2138
2280
|
userID: string;
|
|
2139
2281
|
username: string;
|
|
2140
2282
|
}): InternalUserRecord {
|