@vex-chat/spire 1.0.4 → 1.2.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/package.json CHANGED
@@ -1,122 +1,111 @@
1
1
  {
2
- "name": "@vex-chat/spire",
3
- "version": "1.0.4",
4
- "description": "Vex server implementation in NodeJS.",
5
- "type": "module",
6
- "files": [
7
- "dist",
8
- "src",
9
- "LICENSE",
10
- "LICENSE-COMMERCIAL",
11
- "LICENSING.md",
12
- "CLA.md"
13
- ],
14
- "author": "Extra <extrahash@protonmail.com>",
15
- "license": "AGPL-3.0-or-later",
16
- "engines": {
17
- "node": ">=24.0.0",
18
- "npm": ">=10.0.0"
19
- },
20
- "packageManager": "npm@10.9.2",
21
- "publishConfig": {
22
- "access": "public",
23
- "registry": "https://registry.npmjs.org/"
24
- },
25
- "scripts": {
26
- "preinstall": "npx only-allow npm",
27
- "start": "node --experimental-strip-types src/run.ts",
28
- "restart": "pm2 restart spire && pm2 logs spire",
29
- "build": "tsc && node scripts/clean-dist-tests.mjs",
30
- "format": "prettier --write .",
31
- "format:check": "prettier --check .",
32
- "copyright:check": "node scripts/copyright-headers.mjs --check",
33
- "copyright:apply": "node scripts/copyright-headers.mjs",
34
- "lint": "eslint src/ scripts/stress/",
35
- "lint:fix": "eslint src/ scripts/stress/ --fix",
36
- "prepare": "husky || true",
37
- "test": "vitest run",
38
- "stress:web": "node --experimental-strip-types scripts/stress/spire-stress.ts",
39
- "stress:cli": "node --experimental-strip-types scripts/stress/stress-cli-sweep.ts",
40
- "stress:repo-manifest": "node --experimental-strip-types scripts/stress/stress-repo-manifest.ts",
41
- "stress:docs-pack": "node --experimental-strip-types scripts/stress/stress-docs-pack.ts",
42
- "stress:llm-triage": "node --experimental-strip-types scripts/stress/stress-llm-triage.ts",
43
- "gen-spk": "node scripts/gen-spk.js",
44
- "docs": "node ./scripts/generate-docs.js",
45
- "service:status-monitor": "node ./services/status-monitor/index.js",
46
- "service:status-monitor:clear-history": "node ./services/status-monitor/clear-history.js",
47
- "service:deploy-hook": "node ./services/deploy-hook/index.js",
48
- "monitor": "node ./services/status-monitor/index.js"
49
- },
50
- "devDependencies": {
51
- "@changesets/cli": "2.30.0",
52
- "@socketsecurity/cli": "1.1.84",
53
- "@types/better-sqlite3": "7.6.13",
54
- "@types/cors": "2.8.19",
55
- "@types/express": "5.0.6",
56
- "@types/jsonwebtoken": "9.0.10",
57
- "@types/morgan": "1.9.10",
58
- "@types/multer": "2.1.0",
59
- "@types/node": "25.6.0",
60
- "@types/uuid": "10.0.0",
61
- "@types/ws": "8.18.1",
62
- "@vex-chat/libvex": "5.2.0",
63
- "@vitest/eslint-plugin": "1.6.15",
64
- "axios": "1.15.0",
65
- "eslint": "10.2.0",
66
- "eslint-config-prettier": "10.1.8",
67
- "eslint-plugin-n": "17.24.0",
68
- "eslint-plugin-perfectionist": "5.8.0",
69
- "husky": "9.1.7",
70
- "lint-staged": "16.4.0",
71
- "npm-package-json-lint": "10.2.1",
72
- "prettier": "3.8.2",
73
- "type-coverage": "2.29.7",
74
- "typedoc": "0.28.18",
75
- "typescript": "6.0.2",
76
- "typescript-eslint": "8.58.1",
77
- "vitest": "4.1.4"
78
- },
79
- "typeCoverage": {
80
- "atLeast": 95,
81
- "ignoreCatch": true
82
- },
83
- "lint-staged": {
84
- "*.ts": [
85
- "eslint --fix",
86
- "prettier --write"
87
- ],
88
- "*.{yml,yaml}": [
89
- "prettier --write"
90
- ]
91
- },
92
- "overrides": {
93
- "@types/ws": "8.18.1",
94
- "@types/readable-stream": "4.0.11",
95
- "lodash": "4.18.1",
96
- "minimatch": "10.2.5"
97
- },
98
- "dependencies": {
99
- "@asyncapi/web-component": "2.6.5",
100
- "@scalar/express-api-reference": "0.9.7",
101
- "@vex-chat/crypto": "2.0.1",
102
- "@vex-chat/types": "2.0.0",
103
- "argon2": "0.44.0",
104
- "better-sqlite3": "11.10.0",
105
- "cors": "2.8.6",
106
- "dotenv": "17.4.1",
107
- "express": "5.2.1",
108
- "express-rate-limit": "8.3.2",
109
- "file-type": "22.0.1",
110
- "helmet": "8.1.0",
111
- "jsonwebtoken": "9.0.3",
112
- "kysely": "0.28.16",
113
- "morgan": "1.10.1",
114
- "msgpackr": "1.11.8",
115
- "multer": "2.1.1",
116
- "parse-duration": "2.1.6",
117
- "tweetnacl": "1.0.3",
118
- "uuid": "8.3.2",
119
- "ws": "8.20.0",
120
- "zod": "4.3.6"
121
- }
122
- }
2
+ "name": "@vex-chat/spire",
3
+ "version": "1.2.0",
4
+ "description": "Vex server implementation in NodeJS.",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "src",
9
+ "LICENSE",
10
+ "LICENSE-COMMERCIAL",
11
+ "LICENSING.md",
12
+ "CLA.md"
13
+ ],
14
+ "author": "Extra <extrahash@protonmail.com>",
15
+ "license": "AGPL-3.0-or-later",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/vex-protocol/vex-protocol.git",
19
+ "directory": "apps/spire"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/vex-protocol/vex-protocol/issues"
23
+ },
24
+ "homepage": "https://github.com/vex-protocol/vex-protocol/tree/master/apps/spire#readme",
25
+ "engines": {
26
+ "node": ">=24.0.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "registry": "https://registry.npmjs.org/"
31
+ },
32
+ "devDependencies": {
33
+ "@changesets/cli": "2.30.0",
34
+ "@socketsecurity/cli": "1.1.84",
35
+ "@types/better-sqlite3": "7.6.13",
36
+ "@types/cors": "2.8.19",
37
+ "@types/express": "5.0.6",
38
+ "@types/express-serve-static-core": "5.1.1",
39
+ "@types/jsonwebtoken": "9.0.10",
40
+ "@types/morgan": "1.9.10",
41
+ "@types/multer": "2.1.0",
42
+ "@types/node": "^25.6.0",
43
+ "@types/ws": "8.18.1",
44
+ "@vitest/eslint-plugin": "1.6.15",
45
+ "axios": "1.15.0",
46
+ "eslint": "10.2.0",
47
+ "eslint-config-prettier": "10.1.8",
48
+ "eslint-plugin-n": "17.24.0",
49
+ "eslint-plugin-perfectionist": "5.8.0",
50
+ "husky": "9.1.7",
51
+ "lint-staged": "16.4.0",
52
+ "prettier": "3.8.2",
53
+ "type-coverage": "2.29.7",
54
+ "typedoc": "0.28.18",
55
+ "typescript": "6.0.2",
56
+ "typescript-eslint": "8.58.1",
57
+ "vitest": "4.1.4",
58
+ "@vex-chat/eslint-config": "0.0.1",
59
+ "@vex-chat/libvex": "^6.0.0"
60
+ },
61
+ "typeCoverage": {
62
+ "atLeast": 95,
63
+ "ignoreCatch": true
64
+ },
65
+ "dependencies": {
66
+ "@asyncapi/web-component": "2.6.5",
67
+ "@scalar/express-api-reference": "0.9.7",
68
+ "argon2": "0.44.0",
69
+ "better-sqlite3": "11.10.0",
70
+ "cors": "2.8.6",
71
+ "dotenv": "17.4.1",
72
+ "express": "5.2.1",
73
+ "express-rate-limit": "8.3.2",
74
+ "file-type": "22.0.1",
75
+ "helmet": "8.1.0",
76
+ "jsonwebtoken": "9.0.3",
77
+ "kysely": "^0.28.16",
78
+ "morgan": "1.10.1",
79
+ "msgpackr": "^1.11.9",
80
+ "multer": "2.1.1",
81
+ "parse-duration": "2.1.6",
82
+ "tweetnacl": "1.0.3",
83
+ "uuid": "^14.0.0",
84
+ "ws": "8.20.0",
85
+ "zod": "^4.3.6",
86
+ "@vex-chat/crypto": "^3.0.0",
87
+ "@vex-chat/types": "^3.0.0"
88
+ },
89
+ "scripts": {
90
+ "rebuild:sqlite": "pnpm rebuild better-sqlite3",
91
+ "start": "node --experimental-strip-types src/run.ts",
92
+ "restart": "pm2 restart spire && pm2 logs spire",
93
+ "build": "tsc && node scripts/clean-dist-tests.mjs",
94
+ "format": "prettier --write .",
95
+ "format:check": "prettier --check .",
96
+ "lint": "eslint src/ scripts/stress/",
97
+ "lint:fix": "eslint src/ scripts/stress/ --fix",
98
+ "test": "vitest run",
99
+ "stress:web": "node --experimental-strip-types scripts/stress/spire-stress.ts",
100
+ "stress:cli": "node --experimental-strip-types scripts/stress/stress-cli-sweep.ts",
101
+ "stress:repo-manifest": "node --experimental-strip-types scripts/stress/stress-repo-manifest.ts",
102
+ "stress:docs-pack": "node --experimental-strip-types scripts/stress/stress-docs-pack.ts",
103
+ "stress:llm-triage": "node --experimental-strip-types scripts/stress/stress-llm-triage.ts",
104
+ "gen-spk": "node scripts/gen-spk.js",
105
+ "gen-spk-fips": "node scripts/gen-spk-fips.js",
106
+ "docs": "node ./scripts/generate-docs.js",
107
+ "service:status-monitor": "node ./services/status-monitor/index.js",
108
+ "service:status-monitor:clear-history": "node ./services/status-monitor/clear-history.js",
109
+ "monitor": "node ./services/status-monitor/index.js"
110
+ }
111
+ }
@@ -23,7 +23,6 @@ import { EventEmitter } from "events";
23
23
  import { setTimeout as sleep } from "node:timers/promises";
24
24
 
25
25
  import { xConcat, XUtils } from "@vex-chat/crypto";
26
- import { xSignOpen } from "@vex-chat/crypto";
27
26
  import { MailWSSchema, SocketAuthErrors } from "@vex-chat/types";
28
27
 
29
28
  import { parse as uuidParse, validate as uuidValidate } from "uuid";
@@ -31,6 +30,7 @@ import { parse as uuidParse, validate as uuidValidate } from "uuid";
31
30
  import { TOKEN_EXPIRY } from "./Spire.ts";
32
31
  import { createUint8UUID } from "./utils/createUint8UUID.ts";
33
32
  import { msgpack } from "./utils/msgpack.ts";
33
+ import { spireXSignOpenAsync } from "./utils/spireXSignOpenAsync.ts";
34
34
 
35
35
  export const POWER_LEVELS = {
36
36
  CREATE: 50,
@@ -341,7 +341,7 @@ export class ClientManager extends EventEmitter {
341
341
  const devices = await this.db.retrieveUserDeviceList([user.userID]);
342
342
  let message: null | Uint8Array = null;
343
343
  for (const device of devices) {
344
- const verified = xSignOpen(
344
+ const verified = await spireXSignOpenAsync(
345
345
  msg.signed,
346
346
  XUtils.decodeHex(device.signKey),
347
347
  );
package/src/Database.ts CHANGED
@@ -32,7 +32,11 @@ import * as fs from "node:fs/promises";
32
32
  import path from "node:path";
33
33
  import { fileURLToPath, pathToFileURL } from "node:url";
34
34
 
35
- import { XUtils } from "@vex-chat/crypto";
35
+ import {
36
+ fipsEcdhRawPublicKeyFromEcdsaSpkiAsync,
37
+ getCryptoProfile,
38
+ XUtils,
39
+ } from "@vex-chat/crypto";
36
40
  import { MailType } from "@vex-chat/types";
37
41
 
38
42
  import argon2 from "argon2";
@@ -124,21 +128,9 @@ export interface InternalUserRecord extends UserRecord {
124
128
  }
125
129
 
126
130
  export class Database extends EventEmitter {
127
- private db: Kysely<ServerDatabase>;
128
-
129
- /** Underlying better-sqlite3 handle (file or :memory:). */
130
- private readonly rawSqlite: InstanceType<typeof BetterSqlite3>;
131
+ private static readonly EMOJI_LIST_CACHE_TTL_MS = 10_000;
131
132
 
132
- /**
133
- * In-process cache for {@link retrieveUserDeviceList} when queried for a
134
- * single owner. Libvex calls GET /user/:id/devices on every outgoing
135
- * `forward()`; stress runs otherwise hammer SQLite with identical reads.
136
- * Cleared on device create/delete and when the DB is closed.
137
- */
138
- private readonly userDeviceListCache = new Map<
139
- string,
140
- { devices: Device[]; until: number }
141
- >();
133
+ private db: Kysely<ServerDatabase>;
142
134
 
143
135
  /**
144
136
  * Short TTL cache for {@link retrieveEmojiList} (key = server id, stored as
@@ -151,8 +143,8 @@ export class Database extends EventEmitter {
151
143
  { rows: Emoji[]; until: number }
152
144
  >();
153
145
 
154
- private static readonly USER_DEVICE_LIST_CACHE_TTL_MS = 10_000;
155
- private static readonly EMOJI_LIST_CACHE_TTL_MS = 10_000;
146
+ /** Underlying better-sqlite3 handle (file or :memory:). */
147
+ private readonly rawSqlite: InstanceType<typeof BetterSqlite3>;
156
148
 
157
149
  constructor(options?: SpireOptions) {
158
150
  super();
@@ -188,70 +180,7 @@ export class Database extends EventEmitter {
188
180
  void this.init();
189
181
  }
190
182
 
191
- /**
192
- * Dev-only snapshot of SQLite files + pragmas (same process as Spire).
193
- * Not for production callers.
194
- */
195
- public getDevSqliteMonitor(): Record<string, unknown> {
196
- const openedAs = this.rawSqlite.name;
197
- const absPath =
198
- openedAs === ":memory:"
199
- ? ":memory:"
200
- : path.isAbsolute(openedAs)
201
- ? openedAs
202
- : path.resolve(process.cwd(), openedAs);
203
-
204
- const journalMode = this.rawSqlite.pragma("journal_mode", {
205
- simple: true,
206
- });
207
- const synchronous = this.rawSqlite.pragma("synchronous", {
208
- simple: true,
209
- });
210
- const busyTimeout = this.rawSqlite.pragma("busy_timeout", {
211
- simple: true,
212
- });
213
- const cacheSize = this.rawSqlite.pragma("cache_size", { simple: true });
214
- const pageCount = this.rawSqlite.pragma("page_count", { simple: true });
215
- const pageSize = this.rawSqlite.pragma("page_size", { simple: true });
216
- const freelistCount = this.rawSqlite.pragma("freelist_count", {
217
- simple: true,
218
- });
219
-
220
- const out: Record<string, unknown> = {
221
- absPath,
222
- busyTimeout,
223
- cacheSize,
224
- freelistCount,
225
- journalMode,
226
- openedAs,
227
- pageCount,
228
- pageSize,
229
- synchronous,
230
- };
231
-
232
- if (openedAs !== ":memory:") {
233
- const filePaths = {
234
- main: absPath,
235
- shm: `${absPath}-shm`,
236
- wal: `${absPath}-wal`,
237
- };
238
- out["filePaths"] = filePaths;
239
- const sizes: Record<string, number> = {};
240
- for (const [label, fp] of Object.entries(filePaths)) {
241
- try {
242
- sizes[label] = statSync(fp).size;
243
- } catch {
244
- sizes[label] = 0;
245
- }
246
- }
247
- out["fileBytes"] = sizes;
248
- }
249
-
250
- return out;
251
- }
252
-
253
183
  public async close(): Promise<void> {
254
- this.userDeviceListCache.clear();
255
184
  this.emojiListByServerCache.clear();
256
185
  await this.db.destroy();
257
186
  }
@@ -283,7 +212,6 @@ export class Database extends EventEmitter {
283
212
  };
284
213
 
285
214
  await this.db.insertInto("devices").values(device).execute();
286
- this.userDeviceListCache.delete(owner);
287
215
 
288
216
  const medPreKeys = {
289
217
  deviceID: device.deviceID,
@@ -423,12 +351,6 @@ export class Database extends EventEmitter {
423
351
  }
424
352
 
425
353
  public async deleteDevice(deviceID: string): Promise<void> {
426
- const ownerRow = await this.db
427
- .selectFrom("devices")
428
- .select("owner")
429
- .where("deviceID", "=", deviceID)
430
- .executeTakeFirst();
431
-
432
354
  await this.db
433
355
  .deleteFrom("preKeys")
434
356
  .where("deviceID", "=", deviceID)
@@ -444,10 +366,6 @@ export class Database extends EventEmitter {
444
366
  .set({ deleted: 1 })
445
367
  .where("deviceID", "=", deviceID)
446
368
  .execute();
447
-
448
- if (ownerRow?.owner) {
449
- this.userDeviceListCache.delete(ownerRow.owner);
450
- }
451
369
  }
452
370
 
453
371
  public async deleteEmoji(emojiID: string): Promise<void> {
@@ -502,6 +420,68 @@ export class Database extends EventEmitter {
502
420
  .execute();
503
421
  }
504
422
 
423
+ /**
424
+ * Dev-only snapshot of SQLite files + pragmas (same process as Spire).
425
+ * Not for production callers.
426
+ */
427
+ public getDevSqliteMonitor(): Record<string, unknown> {
428
+ const openedAs = this.rawSqlite.name;
429
+ const absPath =
430
+ openedAs === ":memory:"
431
+ ? ":memory:"
432
+ : path.isAbsolute(openedAs)
433
+ ? openedAs
434
+ : path.resolve(process.cwd(), openedAs);
435
+
436
+ const journalMode = this.rawSqlite.pragma("journal_mode", {
437
+ simple: true,
438
+ });
439
+ const synchronous = this.rawSqlite.pragma("synchronous", {
440
+ simple: true,
441
+ });
442
+ const busyTimeout = this.rawSqlite.pragma("busy_timeout", {
443
+ simple: true,
444
+ });
445
+ const cacheSize = this.rawSqlite.pragma("cache_size", { simple: true });
446
+ const pageCount = this.rawSqlite.pragma("page_count", { simple: true });
447
+ const pageSize = this.rawSqlite.pragma("page_size", { simple: true });
448
+ const freelistCount = this.rawSqlite.pragma("freelist_count", {
449
+ simple: true,
450
+ });
451
+
452
+ const out: Record<string, unknown> = {
453
+ absPath,
454
+ busyTimeout,
455
+ cacheSize,
456
+ freelistCount,
457
+ journalMode,
458
+ openedAs,
459
+ pageCount,
460
+ pageSize,
461
+ synchronous,
462
+ };
463
+
464
+ if (openedAs !== ":memory:") {
465
+ const filePaths = {
466
+ main: absPath,
467
+ shm: `${absPath}-shm`,
468
+ wal: `${absPath}-wal`,
469
+ };
470
+ out["filePaths"] = filePaths;
471
+ const sizes: Record<string, number> = {};
472
+ for (const [label, fp] of Object.entries(filePaths)) {
473
+ try {
474
+ sizes[label] = statSync(fp).size;
475
+ } catch {
476
+ sizes[label] = 0;
477
+ }
478
+ }
479
+ out["fileBytes"] = sizes;
480
+ }
481
+
482
+ return out;
483
+ }
484
+
505
485
  public async getKeyBundle(deviceID: string): Promise<KeyBundle | null> {
506
486
  const device = await this.retrieveDevice(deviceID);
507
487
  if (!device) {
@@ -512,10 +492,15 @@ export class Database extends EventEmitter {
512
492
  if (!preKey) {
513
493
  throw new Error("Failed to get prekey.");
514
494
  }
495
+ const signKeyBytes = XUtils.decodeHex(device.signKey);
496
+ const signKey =
497
+ getCryptoProfile() === "fips"
498
+ ? await fipsEcdhRawPublicKeyFromEcdsaSpkiAsync(signKeyBytes)
499
+ : signKeyBytes;
515
500
  const keyBundle: KeyBundle = {
516
501
  otk,
517
502
  preKey,
518
- signKey: XUtils.decodeHex(device.signKey),
503
+ signKey,
519
504
  };
520
505
  return keyBundle;
521
506
  }
@@ -942,37 +927,20 @@ export class Database extends EventEmitter {
942
927
  return row ? toUserRecord(row) : null;
943
928
  }
944
929
 
930
+ /**
931
+ * All devices for the given user ID(s), **no in-process cache** — a prior
932
+ * 10s TTL was removed: concurrent GET + POST could end with a stale
933
+ * snapshot overwriting the map after a new device was inserted (automation
934
+ * and `POST /user/:id/devices` must see a fresh list on the next read).
935
+ */
945
936
  public async retrieveUserDeviceList(userIDs: string[]): Promise<Device[]> {
946
- const now = Date.now();
947
- if (userIDs.length === 1) {
948
- const owner = userIDs[0];
949
- if (typeof owner === "string") {
950
- const hit = this.userDeviceListCache.get(owner);
951
- if (hit && hit.until > now) {
952
- return hit.devices.map((d) => ({ ...d }));
953
- }
954
- }
955
- }
956
-
957
937
  const rows = await this.db
958
938
  .selectFrom("devices")
959
939
  .selectAll()
960
940
  .where("owner", "in", userIDs)
961
941
  .where("deleted", "=", 0)
962
942
  .execute();
963
- const devices = rows.map(toDevice);
964
-
965
- if (userIDs.length === 1) {
966
- const owner = userIDs[0];
967
- if (typeof owner === "string") {
968
- this.userDeviceListCache.set(owner, {
969
- devices: devices.map((d) => ({ ...d })),
970
- until: now + Database.USER_DEVICE_LIST_CACHE_TTL_MS,
971
- });
972
- }
973
- }
974
-
975
- return devices;
943
+ return rows.map(toDevice);
976
944
  }
977
945
 
978
946
  public async retrieveUsers(): Promise<InternalUserRecord[]> {
@@ -989,6 +957,7 @@ export class Database extends EventEmitter {
989
957
  const entry: MailSQL = {
990
958
  authorID: userID,
991
959
  cipher: XUtils.encodeHex(mail.cipher),
960
+ // Opaque transport metadata (X3DH initial payload or ratchet header).
992
961
  extra: XUtils.encodeHex(mail.extra),
993
962
  forward: mail.forward,
994
963
  group: mail.group ? XUtils.encodeHex(mail.group) : null,