@vex-chat/spire 1.0.0 → 1.0.2
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 +38 -38
- package/dist/ClientManager.d.ts +1 -3
- package/dist/ClientManager.js +7 -50
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +49 -6
- package/dist/Database.js +255 -80
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +0 -3
- package/dist/Spire.js +116 -81
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +1 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.d.ts +3 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +12 -0
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +1 -0
- package/dist/run.js +0 -1
- package/dist/run.js.map +1 -1
- package/dist/server/avatar.d.ts +1 -3
- package/dist/server/avatar.js +7 -11
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/errors.d.ts +3 -6
- package/dist/server/errors.js +2 -28
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.d.ts +1 -2
- package/dist/server/file.js +5 -7
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +1 -2
- package/dist/server/index.js +35 -33
- package/dist/server/index.js.map +1 -1
- package/dist/server/invite.d.ts +1 -2
- package/dist/server/invite.js +1 -1
- package/dist/server/invite.js.map +1 -1
- package/dist/server/rateLimit.d.ts +13 -3
- package/dist/server/rateLimit.js +57 -6
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +1 -2
- package/dist/server/user.js +10 -8
- package/dist/server/user.js.map +1 -1
- package/dist/utils/jwtSecret.d.ts +3 -3
- package/dist/utils/jwtSecret.js +5 -6
- package/dist/utils/jwtSecret.js.map +1 -1
- package/dist/utils/loadEnv.js +1 -1
- package/dist/utils/loadEnv.js.map +1 -1
- package/package.json +17 -8
- package/src/ClientManager.ts +7 -60
- package/src/Database.ts +308 -89
- package/src/Spire.ts +122 -119
- package/src/__tests__/Database.spec.ts +0 -17
- package/src/db/schema.ts +1 -0
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +18 -0
- package/src/run.ts +0 -1
- package/src/server/avatar.ts +7 -14
- package/src/server/errors.ts +3 -32
- package/src/server/file.ts +5 -10
- package/src/server/index.ts +37 -37
- package/src/server/invite.ts +0 -2
- package/src/server/rateLimit.ts +43 -9
- package/src/server/user.ts +9 -9
- package/src/utils/jwtSecret.ts +5 -6
- package/src/utils/loadEnv.ts +1 -1
- package/dist/__tests__/Database.spec.d.ts +0 -1
- package/dist/__tests__/Database.spec.js +0 -136
- package/dist/__tests__/Database.spec.js.map +0 -1
- package/dist/utils/createLogger.d.ts +0 -5
- package/dist/utils/createLogger.js +0 -35
- package/dist/utils/createLogger.js.map +0 -1
- package/src/utils/createLogger.ts +0 -47
package/src/Database.ts
CHANGED
|
@@ -18,17 +18,19 @@ import type {
|
|
|
18
18
|
UserRecord,
|
|
19
19
|
} from "@vex-chat/types";
|
|
20
20
|
import type { Migration, MigrationProvider } from "kysely";
|
|
21
|
-
import type winston from "winston";
|
|
22
21
|
|
|
23
22
|
import { EventEmitter } from "events";
|
|
24
23
|
import { pbkdf2Sync } from "node:crypto";
|
|
24
|
+
import { statSync } from "node:fs";
|
|
25
25
|
import * as fs from "node:fs/promises";
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
28
28
|
|
|
29
|
-
import {
|
|
29
|
+
import { XUtils } from "@vex-chat/crypto";
|
|
30
30
|
import { MailType } from "@vex-chat/types";
|
|
31
31
|
|
|
32
|
+
import argon2 from "argon2";
|
|
33
|
+
|
|
32
34
|
/**
|
|
33
35
|
* Narrow a plain integer from the `mailType` SQL column to the
|
|
34
36
|
* `MailType` union (0 = initial, 1 = subsequent). Throws if the
|
|
@@ -46,8 +48,6 @@ import BetterSqlite3 from "better-sqlite3";
|
|
|
46
48
|
import { Kysely, Migrator, sql, SqliteDialect } from "kysely";
|
|
47
49
|
import { stringify as uuidStringify, validate as uuidValidate } from "uuid";
|
|
48
50
|
|
|
49
|
-
import { createLogger } from "./utils/createLogger.ts";
|
|
50
|
-
|
|
51
51
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
52
52
|
const migrationFolder = path.join(__dirname, "migrations");
|
|
53
53
|
|
|
@@ -104,21 +104,53 @@ function isMigration(mod: unknown): mod is Migration {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
const pubkeyRegex = /[0-9a-f]{64}/;
|
|
107
|
-
|
|
107
|
+
|
|
108
|
+
/** Legacy iteration count kept only for verifying old PBKDF2 hashes. */
|
|
109
|
+
const PBKDF2_ITERATIONS = 1000;
|
|
108
110
|
|
|
109
111
|
// ── Row-to-interface converters ─────────────────────────────────────────
|
|
110
112
|
// SQLite stores booleans as integers and dates as strings, but the
|
|
111
113
|
// @vex-chat/types interfaces expect boolean / Date.
|
|
112
114
|
|
|
115
|
+
/** Internal record that includes the hash algorithm discriminator. */
|
|
116
|
+
export interface InternalUserRecord extends UserRecord {
|
|
117
|
+
hashAlgo: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
113
120
|
export class Database extends EventEmitter {
|
|
114
121
|
private db: Kysely<ServerDatabase>;
|
|
115
|
-
|
|
122
|
+
|
|
123
|
+
/** Underlying better-sqlite3 handle (file or :memory:). */
|
|
124
|
+
private readonly rawSqlite: InstanceType<typeof BetterSqlite3>;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* In-process cache for {@link retrieveUserDeviceList} when queried for a
|
|
128
|
+
* single owner. Libvex calls GET /user/:id/devices on every outgoing
|
|
129
|
+
* `forward()`; stress runs otherwise hammer SQLite with identical reads.
|
|
130
|
+
* Cleared on device create/delete and when the DB is closed.
|
|
131
|
+
*/
|
|
132
|
+
private readonly userDeviceListCache = new Map<
|
|
133
|
+
string,
|
|
134
|
+
{ devices: Device[]; until: number }
|
|
135
|
+
>();
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Short TTL cache for {@link retrieveEmojiList} (key = server id, stored as
|
|
139
|
+
* `emojis.owner`). The noise harness fires `emoji.retrieveList` as a
|
|
140
|
+
* follow-up on ~1/25 successful ops, so this path can contend on SQLite
|
|
141
|
+
* before heavier surfaces. Invalidated on emoji create/delete.
|
|
142
|
+
*/
|
|
143
|
+
private readonly emojiListByServerCache = new Map<
|
|
144
|
+
string,
|
|
145
|
+
{ rows: Emoji[]; until: number }
|
|
146
|
+
>();
|
|
147
|
+
|
|
148
|
+
private static readonly USER_DEVICE_LIST_CACHE_TTL_MS = 10_000;
|
|
149
|
+
private static readonly EMOJI_LIST_CACHE_TTL_MS = 10_000;
|
|
116
150
|
|
|
117
151
|
constructor(options?: SpireOptions) {
|
|
118
152
|
super();
|
|
119
153
|
|
|
120
|
-
this.log = createLogger("spire-db", options?.logLevel || "error");
|
|
121
|
-
|
|
122
154
|
const dbType = options?.dbType || "sqlite3";
|
|
123
155
|
|
|
124
156
|
let filename: string;
|
|
@@ -135,23 +167,86 @@ export class Database extends EventEmitter {
|
|
|
135
167
|
break;
|
|
136
168
|
}
|
|
137
169
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
170
|
+
this.rawSqlite = new BetterSqlite3(filename);
|
|
171
|
+
this.rawSqlite.pragma("journal_mode = WAL");
|
|
172
|
+
this.rawSqlite.pragma("synchronous = NORMAL");
|
|
173
|
+
this.rawSqlite.pragma("busy_timeout = 5000");
|
|
174
|
+
this.rawSqlite.pragma("cache_size = -64000");
|
|
175
|
+
this.rawSqlite.pragma("temp_store = memory");
|
|
176
|
+
this.rawSqlite.pragma("foreign_keys = ON");
|
|
145
177
|
|
|
146
178
|
this.db = new Kysely<ServerDatabase>({
|
|
147
|
-
dialect: new SqliteDialect({ database:
|
|
179
|
+
dialect: new SqliteDialect({ database: this.rawSqlite }),
|
|
148
180
|
});
|
|
149
181
|
|
|
150
182
|
void this.init();
|
|
151
183
|
}
|
|
152
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Dev-only snapshot of SQLite files + pragmas (same process as Spire).
|
|
187
|
+
* Not for production callers.
|
|
188
|
+
*/
|
|
189
|
+
public getDevSqliteMonitor(): Record<string, unknown> {
|
|
190
|
+
const openedAs = this.rawSqlite.name;
|
|
191
|
+
const absPath =
|
|
192
|
+
openedAs === ":memory:"
|
|
193
|
+
? ":memory:"
|
|
194
|
+
: path.isAbsolute(openedAs)
|
|
195
|
+
? openedAs
|
|
196
|
+
: path.resolve(process.cwd(), openedAs);
|
|
197
|
+
|
|
198
|
+
const journalMode = this.rawSqlite.pragma("journal_mode", {
|
|
199
|
+
simple: true,
|
|
200
|
+
});
|
|
201
|
+
const synchronous = this.rawSqlite.pragma("synchronous", {
|
|
202
|
+
simple: true,
|
|
203
|
+
});
|
|
204
|
+
const busyTimeout = this.rawSqlite.pragma("busy_timeout", {
|
|
205
|
+
simple: true,
|
|
206
|
+
});
|
|
207
|
+
const cacheSize = this.rawSqlite.pragma("cache_size", { simple: true });
|
|
208
|
+
const pageCount = this.rawSqlite.pragma("page_count", { simple: true });
|
|
209
|
+
const pageSize = this.rawSqlite.pragma("page_size", { simple: true });
|
|
210
|
+
const freelistCount = this.rawSqlite.pragma("freelist_count", {
|
|
211
|
+
simple: true,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const out: Record<string, unknown> = {
|
|
215
|
+
absPath,
|
|
216
|
+
busyTimeout,
|
|
217
|
+
cacheSize,
|
|
218
|
+
freelistCount,
|
|
219
|
+
journalMode,
|
|
220
|
+
openedAs,
|
|
221
|
+
pageCount,
|
|
222
|
+
pageSize,
|
|
223
|
+
synchronous,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (openedAs !== ":memory:") {
|
|
227
|
+
const filePaths = {
|
|
228
|
+
main: absPath,
|
|
229
|
+
shm: `${absPath}-shm`,
|
|
230
|
+
wal: `${absPath}-wal`,
|
|
231
|
+
};
|
|
232
|
+
out["filePaths"] = filePaths;
|
|
233
|
+
const sizes: Record<string, number> = {};
|
|
234
|
+
for (const [label, fp] of Object.entries(filePaths)) {
|
|
235
|
+
try {
|
|
236
|
+
sizes[label] = statSync(fp).size;
|
|
237
|
+
} catch {
|
|
238
|
+
sizes[label] = 0;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
out["fileBytes"] = sizes;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
153
247
|
public async close(): Promise<void> {
|
|
154
|
-
this.
|
|
248
|
+
this.userDeviceListCache.clear();
|
|
249
|
+
this.emojiListByServerCache.clear();
|
|
155
250
|
await this.db.destroy();
|
|
156
251
|
}
|
|
157
252
|
|
|
@@ -182,6 +277,7 @@ export class Database extends EventEmitter {
|
|
|
182
277
|
};
|
|
183
278
|
|
|
184
279
|
await this.db.insertInto("devices").values(device).execute();
|
|
280
|
+
this.userDeviceListCache.delete(owner);
|
|
185
281
|
|
|
186
282
|
const medPreKeys = {
|
|
187
283
|
deviceID: device.deviceID,
|
|
@@ -199,6 +295,7 @@ export class Database extends EventEmitter {
|
|
|
199
295
|
|
|
200
296
|
public async createEmoji(emoji: Emoji): Promise<void> {
|
|
201
297
|
await this.db.insertInto("emojis").values(emoji).execute();
|
|
298
|
+
this.emojiListByServerCache.delete(emoji.owner);
|
|
202
299
|
}
|
|
203
300
|
|
|
204
301
|
public async createFile(file: FileSQL): Promise<void> {
|
|
@@ -228,30 +325,31 @@ export class Database extends EventEmitter {
|
|
|
228
325
|
resourceID: string,
|
|
229
326
|
powerLevel: number,
|
|
230
327
|
): Promise<Permission> {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
328
|
+
// Atomic check-then-insert inside a transaction so two concurrent
|
|
329
|
+
// callers cannot both find "no existing" and insert duplicates.
|
|
330
|
+
return this.db.transaction().execute(async (trx) => {
|
|
331
|
+
const checkPermission = await trx
|
|
332
|
+
.selectFrom("permissions")
|
|
333
|
+
.selectAll()
|
|
334
|
+
.where("userID", "=", userID)
|
|
335
|
+
.where("resourceID", "=", resourceID)
|
|
336
|
+
.execute();
|
|
337
|
+
const existing = checkPermission[0];
|
|
338
|
+
if (existing) {
|
|
339
|
+
return existing;
|
|
340
|
+
}
|
|
244
341
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
342
|
+
const permission: Permission = {
|
|
343
|
+
permissionID: crypto.randomUUID(),
|
|
344
|
+
powerLevel,
|
|
345
|
+
resourceID,
|
|
346
|
+
resourceType,
|
|
347
|
+
userID,
|
|
348
|
+
};
|
|
252
349
|
|
|
253
|
-
|
|
254
|
-
|
|
350
|
+
await trx.insertInto("permissions").values(permission).execute();
|
|
351
|
+
return permission;
|
|
352
|
+
});
|
|
255
353
|
}
|
|
256
354
|
|
|
257
355
|
public async createServer(name: string, ownerID: string): Promise<Server> {
|
|
@@ -280,13 +378,12 @@ export class Database extends EventEmitter {
|
|
|
280
378
|
regPayload: RegistrationPayload,
|
|
281
379
|
): Promise<[null | UserRecord, Error | null]> {
|
|
282
380
|
try {
|
|
283
|
-
const
|
|
284
|
-
const passwordHash = hashPassword(regPayload.password, salt);
|
|
381
|
+
const passwordHash = await hashPasswordArgon2(regPayload.password);
|
|
285
382
|
|
|
286
383
|
const user: UserRecord = {
|
|
287
384
|
lastSeen: new Date().toISOString(),
|
|
288
|
-
passwordHash
|
|
289
|
-
passwordSalt:
|
|
385
|
+
passwordHash,
|
|
386
|
+
passwordSalt: "",
|
|
290
387
|
userID: uuidStringify(regKey),
|
|
291
388
|
username: regPayload.username,
|
|
292
389
|
};
|
|
@@ -295,6 +392,7 @@ export class Database extends EventEmitter {
|
|
|
295
392
|
.insertInto("users")
|
|
296
393
|
.values({
|
|
297
394
|
...user,
|
|
395
|
+
hashAlgo: "argon2id",
|
|
298
396
|
lastSeen: user.lastSeen,
|
|
299
397
|
})
|
|
300
398
|
.execute();
|
|
@@ -319,6 +417,12 @@ export class Database extends EventEmitter {
|
|
|
319
417
|
}
|
|
320
418
|
|
|
321
419
|
public async deleteDevice(deviceID: string): Promise<void> {
|
|
420
|
+
const ownerRow = await this.db
|
|
421
|
+
.selectFrom("devices")
|
|
422
|
+
.select("owner")
|
|
423
|
+
.where("deviceID", "=", deviceID)
|
|
424
|
+
.executeTakeFirst();
|
|
425
|
+
|
|
322
426
|
await this.db
|
|
323
427
|
.deleteFrom("preKeys")
|
|
324
428
|
.where("deviceID", "=", deviceID)
|
|
@@ -334,13 +438,21 @@ export class Database extends EventEmitter {
|
|
|
334
438
|
.set({ deleted: 1 })
|
|
335
439
|
.where("deviceID", "=", deviceID)
|
|
336
440
|
.execute();
|
|
441
|
+
|
|
442
|
+
if (ownerRow?.owner) {
|
|
443
|
+
this.userDeviceListCache.delete(ownerRow.owner);
|
|
444
|
+
}
|
|
337
445
|
}
|
|
338
446
|
|
|
339
447
|
public async deleteEmoji(emojiID: string): Promise<void> {
|
|
448
|
+
const existing = await this.retrieveEmoji(emojiID);
|
|
340
449
|
await this.db
|
|
341
450
|
.deleteFrom("emojis")
|
|
342
451
|
.where("emojiID", "=", emojiID)
|
|
343
452
|
.execute();
|
|
453
|
+
if (existing) {
|
|
454
|
+
this.emojiListByServerCache.delete(existing.owner);
|
|
455
|
+
}
|
|
344
456
|
}
|
|
345
457
|
|
|
346
458
|
public async deleteInvite(inviteID: string): Promise<void> {
|
|
@@ -403,31 +515,34 @@ export class Database extends EventEmitter {
|
|
|
403
515
|
}
|
|
404
516
|
|
|
405
517
|
public async getOTK(deviceID: string): Promise<null | PreKeysWS> {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
publicKey: XUtils.decodeHex(otkInfo.publicKey),
|
|
421
|
-
signature: XUtils.decodeHex(otkInfo.signature),
|
|
422
|
-
};
|
|
518
|
+
// Atomic select-then-delete inside a transaction so two concurrent
|
|
519
|
+
// callers cannot dispense the same one-time key.
|
|
520
|
+
return this.db.transaction().execute(async (trx) => {
|
|
521
|
+
const rows: PreKeysSQL[] = await trx
|
|
522
|
+
.selectFrom("oneTimeKeys")
|
|
523
|
+
.selectAll()
|
|
524
|
+
.where("deviceID", "=", deviceID)
|
|
525
|
+
.orderBy("index")
|
|
526
|
+
.limit(1)
|
|
527
|
+
.execute();
|
|
528
|
+
const otkInfo = rows[0];
|
|
529
|
+
if (!otkInfo) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
423
532
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
533
|
+
await trx
|
|
534
|
+
.deleteFrom("oneTimeKeys")
|
|
535
|
+
.where("deviceID", "=", deviceID)
|
|
536
|
+
.where("index", "=", otkInfo.index)
|
|
537
|
+
.execute();
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
deviceID: otkInfo.deviceID,
|
|
541
|
+
index: otkInfo.index,
|
|
542
|
+
publicKey: XUtils.decodeHex(otkInfo.publicKey),
|
|
543
|
+
signature: XUtils.decodeHex(otkInfo.signature),
|
|
544
|
+
};
|
|
545
|
+
});
|
|
431
546
|
}
|
|
432
547
|
|
|
433
548
|
public async getOTKCount(deviceID: string): Promise<number> {
|
|
@@ -489,8 +604,8 @@ export class Database extends EventEmitter {
|
|
|
489
604
|
try {
|
|
490
605
|
await sql`select 1 as ok`.execute(this.db);
|
|
491
606
|
return true;
|
|
492
|
-
} catch (
|
|
493
|
-
|
|
607
|
+
} catch (_err: unknown) {
|
|
608
|
+
// debugger: health check failed
|
|
494
609
|
return false;
|
|
495
610
|
}
|
|
496
611
|
}
|
|
@@ -511,6 +626,21 @@ export class Database extends EventEmitter {
|
|
|
511
626
|
.execute();
|
|
512
627
|
}
|
|
513
628
|
|
|
629
|
+
public async rehashPassword(
|
|
630
|
+
userID: string,
|
|
631
|
+
newHash: string,
|
|
632
|
+
): Promise<void> {
|
|
633
|
+
await this.db
|
|
634
|
+
.updateTable("users")
|
|
635
|
+
.set({
|
|
636
|
+
hashAlgo: "argon2id",
|
|
637
|
+
passwordHash: newHash,
|
|
638
|
+
passwordSalt: "",
|
|
639
|
+
})
|
|
640
|
+
.where("userID", "=", userID)
|
|
641
|
+
.execute();
|
|
642
|
+
}
|
|
643
|
+
|
|
514
644
|
/**
|
|
515
645
|
* Retrives a list of users that should be notified when a specific resourceID
|
|
516
646
|
* experiences changes.
|
|
@@ -588,12 +718,25 @@ export class Database extends EventEmitter {
|
|
|
588
718
|
return rows[0] ?? null;
|
|
589
719
|
}
|
|
590
720
|
|
|
591
|
-
public async retrieveEmojiList(
|
|
592
|
-
|
|
721
|
+
public async retrieveEmojiList(serverID: string): Promise<Emoji[]> {
|
|
722
|
+
const now = Date.now();
|
|
723
|
+
const hit = this.emojiListByServerCache.get(serverID);
|
|
724
|
+
if (hit && hit.until > now) {
|
|
725
|
+
return hit.rows.map((r) => ({ ...r }));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const rows: Emoji[] = await this.db
|
|
593
729
|
.selectFrom("emojis")
|
|
594
730
|
.selectAll()
|
|
595
|
-
.where("owner", "=",
|
|
731
|
+
.where("owner", "=", serverID)
|
|
596
732
|
.execute();
|
|
733
|
+
|
|
734
|
+
this.emojiListByServerCache.set(serverID, {
|
|
735
|
+
rows: rows.map((r) => ({ ...r })),
|
|
736
|
+
until: now + Database.EMOJI_LIST_CACHE_TTL_MS,
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
return rows.map((r) => ({ ...r }));
|
|
597
740
|
}
|
|
598
741
|
|
|
599
742
|
public async retrieveFile(fileID: string): Promise<FileSQL | null> {
|
|
@@ -735,17 +878,25 @@ export class Database extends EventEmitter {
|
|
|
735
878
|
.where("serverID", "=", serverID)
|
|
736
879
|
.execute();
|
|
737
880
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
if (!
|
|
744
|
-
|
|
881
|
+
const nowMs = Date.now();
|
|
882
|
+
const expiredIds: string[] = [];
|
|
883
|
+
const valid: Invite[] = [];
|
|
884
|
+
for (const row of rows) {
|
|
885
|
+
const expMs = new Date(row.expiration).getTime();
|
|
886
|
+
if (!Number.isFinite(expMs) || expMs <= nowMs) {
|
|
887
|
+
expiredIds.push(row.inviteID);
|
|
888
|
+
continue;
|
|
745
889
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
890
|
+
valid.push(row);
|
|
891
|
+
}
|
|
892
|
+
if (expiredIds.length > 0) {
|
|
893
|
+
await this.db
|
|
894
|
+
.deleteFrom("invites")
|
|
895
|
+
.where("serverID", "=", serverID)
|
|
896
|
+
.where("inviteID", "in", expiredIds)
|
|
897
|
+
.execute();
|
|
898
|
+
}
|
|
899
|
+
return valid;
|
|
749
900
|
}
|
|
750
901
|
|
|
751
902
|
public async retrieveServers(userID: string): Promise<Server[]> {
|
|
@@ -763,7 +914,7 @@ export class Database extends EventEmitter {
|
|
|
763
914
|
// the identifier can be username, public key, or userID
|
|
764
915
|
public async retrieveUser(
|
|
765
916
|
userIdentifier: string,
|
|
766
|
-
): Promise<
|
|
917
|
+
): Promise<InternalUserRecord | null> {
|
|
767
918
|
let rows;
|
|
768
919
|
if (uuidValidate(userIdentifier)) {
|
|
769
920
|
rows = await this.db
|
|
@@ -786,16 +937,39 @@ export class Database extends EventEmitter {
|
|
|
786
937
|
}
|
|
787
938
|
|
|
788
939
|
public async retrieveUserDeviceList(userIDs: string[]): Promise<Device[]> {
|
|
940
|
+
const now = Date.now();
|
|
941
|
+
if (userIDs.length === 1) {
|
|
942
|
+
const owner = userIDs[0];
|
|
943
|
+
if (typeof owner === "string") {
|
|
944
|
+
const hit = this.userDeviceListCache.get(owner);
|
|
945
|
+
if (hit && hit.until > now) {
|
|
946
|
+
return hit.devices.map((d) => ({ ...d }));
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
789
951
|
const rows = await this.db
|
|
790
952
|
.selectFrom("devices")
|
|
791
953
|
.selectAll()
|
|
792
954
|
.where("owner", "in", userIDs)
|
|
793
955
|
.where("deleted", "=", 0)
|
|
794
956
|
.execute();
|
|
795
|
-
|
|
957
|
+
const devices = rows.map(toDevice);
|
|
958
|
+
|
|
959
|
+
if (userIDs.length === 1) {
|
|
960
|
+
const owner = userIDs[0];
|
|
961
|
+
if (typeof owner === "string") {
|
|
962
|
+
this.userDeviceListCache.set(owner, {
|
|
963
|
+
devices: devices.map((d) => ({ ...d })),
|
|
964
|
+
until: now + Database.USER_DEVICE_LIST_CACHE_TTL_MS,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return devices;
|
|
796
970
|
}
|
|
797
971
|
|
|
798
|
-
public async retrieveUsers(): Promise<
|
|
972
|
+
public async retrieveUsers(): Promise<InternalUserRecord[]> {
|
|
799
973
|
const rows = await this.db.selectFrom("users").selectAll().execute();
|
|
800
974
|
return rows.map(toUserRecord);
|
|
801
975
|
}
|
|
@@ -864,6 +1038,53 @@ export class Database extends EventEmitter {
|
|
|
864
1038
|
}
|
|
865
1039
|
}
|
|
866
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* Hash a password with Argon2id (new default).
|
|
1043
|
+
* Returns the encoded hash string which embeds salt, params, and digest.
|
|
1044
|
+
*/
|
|
1045
|
+
export async function hashPasswordArgon2(password: string): Promise<string> {
|
|
1046
|
+
return argon2.hash(password, {
|
|
1047
|
+
memoryCost: 65536,
|
|
1048
|
+
parallelism: 4,
|
|
1049
|
+
timeCost: 3,
|
|
1050
|
+
type: argon2.argon2id,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Verify a password against either Argon2id or legacy PBKDF2 storage.
|
|
1056
|
+
* Returns `{ valid, needsRehash }` — callers should rehash on success
|
|
1057
|
+
* when `needsRehash` is true.
|
|
1058
|
+
*/
|
|
1059
|
+
export async function verifyPassword(
|
|
1060
|
+
password: string,
|
|
1061
|
+
stored: { hashAlgo: string; passwordHash: string; passwordSalt: string },
|
|
1062
|
+
): Promise<{ needsRehash: boolean; valid: boolean }> {
|
|
1063
|
+
if (stored.hashAlgo === "argon2id") {
|
|
1064
|
+
const valid = await argon2.verify(stored.passwordHash, password);
|
|
1065
|
+
return { needsRehash: false, valid };
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Legacy PBKDF2 path
|
|
1069
|
+
const salt = XUtils.decodeHex(stored.passwordSalt);
|
|
1070
|
+
const computed = pbkdf2Sync(
|
|
1071
|
+
password,
|
|
1072
|
+
salt,
|
|
1073
|
+
PBKDF2_ITERATIONS,
|
|
1074
|
+
32,
|
|
1075
|
+
"sha512",
|
|
1076
|
+
);
|
|
1077
|
+
const storedBuf = XUtils.decodeHex(stored.passwordHash);
|
|
1078
|
+
|
|
1079
|
+
if (computed.length !== storedBuf.length) {
|
|
1080
|
+
return { needsRehash: false, valid: false };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const { timingSafeEqual } = await import("node:crypto");
|
|
1084
|
+
const valid = timingSafeEqual(computed, storedBuf);
|
|
1085
|
+
return { needsRehash: valid, valid };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
867
1088
|
function toDevice(row: {
|
|
868
1089
|
deleted: number;
|
|
869
1090
|
deviceID: string;
|
|
@@ -912,14 +1133,12 @@ function toServer(row: {
|
|
|
912
1133
|
}
|
|
913
1134
|
|
|
914
1135
|
function toUserRecord(row: {
|
|
1136
|
+
hashAlgo: string;
|
|
915
1137
|
lastSeen: string;
|
|
916
1138
|
passwordHash: string;
|
|
917
1139
|
passwordSalt: string;
|
|
918
1140
|
userID: string;
|
|
919
1141
|
username: string;
|
|
920
|
-
}):
|
|
1142
|
+
}): InternalUserRecord {
|
|
921
1143
|
return { ...row };
|
|
922
1144
|
}
|
|
923
|
-
|
|
924
|
-
export const hashPassword = (password: string, salt: Uint8Array) =>
|
|
925
|
-
pbkdf2Sync(password, salt, ITERATIONS, 32, "sha512");
|