@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/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
|
|
76
|
-
|
|
|
77
|
-
| `SPIRE_FIPS`
|
|
78
|
-
| `API_PORT`
|
|
79
|
-
| `NODE_ENV`
|
|
80
|
-
| `CORS_ORIGINS`
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
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
|
|
package/dist/Database.d.ts
CHANGED
|
@@ -28,10 +28,8 @@ export interface StoreTransactionRecordInput {
|
|
|
28
28
|
subscriptionID: string;
|
|
29
29
|
userID: string;
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
export
|
|
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<
|
|
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
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|
|
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 =
|
|
90
|
-
|
|
91
|
-
const
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
.
|
|
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
|
|
1174
|
-
// username
|
|
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(
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
-
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
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
|
|
1502
|
+
if (stored === null) {
|
|
1503
|
+
await argon2.verify(DUMMY_PASSWORD_HASH, password);
|
|
1455
1504
|
return { needsRehash: false, valid: false };
|
|
1456
1505
|
}
|
|
1457
|
-
|
|
1506
|
+
try {
|
|
1458
1507
|
const valid = await argon2.verify(stored.passwordHash, password);
|
|
1459
|
-
return {
|
|
1508
|
+
return {
|
|
1509
|
+
needsRehash: valid &&
|
|
1510
|
+
argon2.needsRehash(stored.passwordHash, ARGON2_OPTIONS),
|
|
1511
|
+
valid,
|
|
1512
|
+
};
|
|
1460
1513
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
|
1534
|
-
|
|
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);
|