@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/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;
|
|
@@ -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<
|
|
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
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
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
|
|
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) {
|
|
@@ -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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
.
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
|
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
|
|
1570
|
+
if (stored === null) {
|
|
1571
|
+
await argon2.verify(DUMMY_PASSWORD_HASH, password);
|
|
1455
1572
|
return { needsRehash: false, valid: false };
|
|
1456
1573
|
}
|
|
1457
|
-
|
|
1574
|
+
try {
|
|
1458
1575
|
const valid = await argon2.verify(stored.passwordHash, password);
|
|
1459
|
-
return {
|
|
1576
|
+
return {
|
|
1577
|
+
needsRehash: valid &&
|
|
1578
|
+
argon2.needsRehash(stored.passwordHash, ARGON2_OPTIONS),
|
|
1579
|
+
valid,
|
|
1580
|
+
};
|
|
1460
1581
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
|
1534
|
-
|
|
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);
|