@vex-chat/spire 1.0.3 → 1.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/src/Spire.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ActionToken, BaseMsg, NotifyMsg, User } from "@vex-chat/types";
8
- import type { Server } from "http";
8
+ import type { IncomingMessage, Server } from "http";
9
9
 
10
10
  import { EventEmitter } from "events";
11
11
  import { execSync } from "node:child_process";
@@ -19,9 +19,10 @@ import express from "express";
19
19
  import { XUtils } from "@vex-chat/crypto";
20
20
  import {
21
21
  type KeyPair,
22
+ setCryptoProfile,
22
23
  xRandomBytes,
23
24
  xSignKeyPairFromSecret,
24
- xSignOpen,
25
+ xSignKeyPairFromSecretAsync,
25
26
  } from "@vex-chat/crypto";
26
27
  import {
27
28
  MailWSSchema,
@@ -31,6 +32,7 @@ import {
31
32
  } from "@vex-chat/types";
32
33
 
33
34
  import jwt from "jsonwebtoken";
35
+ import morgan from "morgan";
34
36
  import { stringify as uuidStringify } from "uuid";
35
37
  import { WebSocketServer } from "ws";
36
38
  import { z } from "zod/v4";
@@ -40,13 +42,15 @@ import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
40
42
  import { initApp, protect } from "./server/index.ts";
41
43
  import { authLimiter, devApiKeySkipsRateLimits } from "./server/rateLimit.ts";
42
44
  import { censorUser, getParam, getUser } from "./server/utils.ts";
45
+ import { resolveSpireListenPort } from "./spireListenPort.ts";
43
46
  import { getJwtSecret } from "./utils/jwtSecret.ts";
44
47
  import { msgpack } from "./utils/msgpack.ts";
48
+ import { spireXSignOpenAsync } from "./utils/spireXSignOpenAsync.ts";
45
49
 
46
50
  // expiry of regkeys = 24hr
47
51
  export const TOKEN_EXPIRY = 1000 * 60 * 10;
48
52
  export const JWT_EXPIRY = "7d";
49
- export const DEVICE_AUTH_JWT_EXPIRY = "1h";
53
+ export const DEVICE_AUTH_JWT_EXPIRY = "7d";
50
54
  const DEVICE_CHALLENGE_EXPIRY = 1000 * 60; // 60 seconds
51
55
 
52
56
  // 3-19 chars long
@@ -132,15 +136,40 @@ const getCommitSha = (): string => {
132
136
  };
133
137
 
134
138
  export interface SpireOptions {
139
+ /**
140
+ * TCP port for the HTTP/WS server. If omitted, `run.ts` + `resolveSpireListenPort`
141
+ * use the default (16777 for all profiles; crypto mode is in `GET /status`). Env: `API_PORT`.
142
+ */
135
143
  apiPort?: number;
144
+ /** Default `tweetnacl`. For `fips`, use `Spire.createAsync` (FIPS key load is async). */
145
+ cryptoProfile?: "fips" | "tweetnacl";
136
146
  dbType?: "mysql" | "sqlite3" | "sqlite3mem" | "sqlite";
137
147
  }
138
148
 
149
+ /**
150
+ * Masks identifying material in the access-log URL: hyphenated UUIDs, and
151
+ * `/device/...` path segments that hold public-key material (32B ed25519 hex, or
152
+ * a longer P-256 SPKI hex in FIPS) — not the same pattern as a UUID.
153
+ */
154
+ function redactAccessLogUrl(url: string): string {
155
+ let s = url.replace(
156
+ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
157
+ "[uuid]",
158
+ );
159
+ // Device id is a hex public key, not a UUID: strip the segment after /device/
160
+ s = s.replace(/(\/device\/)([0-9a-fA-F]{16,})(?=[/?#]|$)/g, "$1[device]");
161
+ return s;
162
+ }
163
+
164
+ /** FIPS: sign key loaded inside `Spire.createAsync` before the constructor runs. */
165
+ let pendingFipsKeyPair: KeyPair | null = null;
166
+
139
167
  export class Spire extends EventEmitter {
140
168
  private actionTokens: ActionToken[] = [];
141
169
  private api = express();
142
170
  private clients: ClientManager[] = [];
143
171
  private readonly commitSha = getCommitSha();
172
+ private readonly cryptoProfile: "fips" | "tweetnacl";
144
173
  private db: Database;
145
174
  private dbReady = false;
146
175
  private deviceChallenges = new Map<
@@ -148,13 +177,13 @@ export class Spire extends EventEmitter {
148
177
  { deviceID: string; nonce: string; time: number }
149
178
  >();
150
179
  private queuedRequestIncrements = 0;
180
+
151
181
  private requestsTotal = 0;
152
182
 
153
183
  private requestsTotalLoaded = false;
154
-
155
184
  private server: null | Server = null;
156
- private signKeys: KeyPair;
157
185
 
186
+ private signKeys: KeyPair;
158
187
  private readonly startedAt = new Date();
159
188
  private readonly version = getAppVersion();
160
189
  private wss: WebSocketServer = new WebSocketServer({
@@ -164,7 +193,19 @@ export class Spire extends EventEmitter {
164
193
 
165
194
  constructor(SK: string, options?: SpireOptions) {
166
195
  super();
167
- this.signKeys = xSignKeyPairFromSecret(XUtils.decodeHex(SK));
196
+ this.cryptoProfile = options?.cryptoProfile ?? "tweetnacl";
197
+ if (pendingFipsKeyPair) {
198
+ this.signKeys = pendingFipsKeyPair;
199
+ pendingFipsKeyPair = null;
200
+ } else {
201
+ if (this.cryptoProfile === "fips") {
202
+ throw new Error(
203
+ 'FIPS: use `await Spire.createAsync(secretKeyHex, { ...options, cryptoProfile: "fips" })` instead of `new Spire()`.',
204
+ );
205
+ }
206
+ setCryptoProfile(this.cryptoProfile);
207
+ this.signKeys = xSignKeyPairFromSecret(XUtils.decodeHex(SK));
208
+ }
168
209
 
169
210
  // Trust a single proxy hop (nginx / cloudflare / load balancer).
170
211
  // Required so `req.ip` and `express-rate-limit`'s keyGenerator see
@@ -181,7 +222,34 @@ export class Spire extends EventEmitter {
181
222
  });
182
223
  });
183
224
 
184
- this.init(options?.apiPort || 16777);
225
+ this.init(resolveSpireListenPort(options?.apiPort));
226
+ }
227
+
228
+ /**
229
+ * Construct Spire when the crypto profile is `fips` (async sign-key derivation).
230
+ * For `tweetnacl` (default) you can use `new Spire()` directly.
231
+ */
232
+ public static async createAsync(
233
+ secretKeyHex: string,
234
+ options?: SpireOptions,
235
+ ): Promise<Spire> {
236
+ if ((options?.cryptoProfile ?? "tweetnacl") !== "fips") {
237
+ return new Spire(secretKeyHex, options);
238
+ }
239
+ if (typeof globalThis.crypto.subtle !== "object") {
240
+ throw new Error(
241
+ "FIPS: Spire requires `globalThis.crypto.subtle` (e.g. Node 20+ with global Web Crypto).",
242
+ );
243
+ }
244
+ setCryptoProfile("fips");
245
+ try {
246
+ pendingFipsKeyPair = await xSignKeyPairFromSecretAsync(
247
+ XUtils.decodeHex(secretKeyHex),
248
+ );
249
+ return new Spire(secretKeyHex, options);
250
+ } finally {
251
+ pendingFipsKeyPair = null;
252
+ }
185
253
  }
186
254
 
187
255
  public async close(): Promise<void> {
@@ -232,6 +300,21 @@ export class Spire extends EventEmitter {
232
300
  }
233
301
 
234
302
  private init(apiPort: number): void {
303
+ // Request traces (UUIDs and device public-key path segments redacted
304
+ // in the `url` token). Enabled in all envs, including production.
305
+ const accessFlag = process.env["SPIRE_HTTP_ACCESS_LOG"];
306
+ const accessLogEnabled =
307
+ accessFlag !== "0" &&
308
+ accessFlag !== "false" &&
309
+ accessFlag !== "off";
310
+ if (accessLogEnabled) {
311
+ morgan.token("url", (req: IncomingMessage) => {
312
+ const r = req as IncomingMessage & { originalUrl?: string };
313
+ return redactAccessLogUrl(r.originalUrl ?? r.url ?? "");
314
+ });
315
+ this.api.use(morgan("dev"));
316
+ }
317
+
235
318
  this.api.use((_req, _res, next) => {
236
319
  this.requestsTotal += 1;
237
320
 
@@ -432,12 +515,16 @@ export class Spire extends EventEmitter {
432
515
  res.json({ dbReady: true, ok: true });
433
516
  });
434
517
 
435
- this.api.get("/status", async (_req, res) => {
518
+ this.api.get("/status", async (req, res) => {
436
519
  const started = Date.now();
437
520
  const dbHealthy = this.dbReady ? await this.db.isHealthy() : false;
438
521
  const checkDurationMs = Date.now() - started;
439
522
 
440
523
  const ok = dbHealthy;
524
+ if (!devApiKeySkipsRateLimits(req)) {
525
+ res.json({ cryptoProfile: this.cryptoProfile, ok });
526
+ return;
527
+ }
441
528
  const canaryEnv = process.env["CANARY"]?.trim().toLowerCase();
442
529
  res.json({
443
530
  canary:
@@ -445,6 +532,7 @@ export class Spire extends EventEmitter {
445
532
  canaryEnv === "true" ||
446
533
  canaryEnv === "yes",
447
534
  checkDurationMs,
535
+ cryptoProfile: this.cryptoProfile,
448
536
  now: new Date(),
449
537
  ok,
450
538
  version: this.version,
@@ -592,7 +680,7 @@ export class Spire extends EventEmitter {
592
680
  }
593
681
 
594
682
  // Verify the Ed25519 signature
595
- const opened = xSignOpen(
683
+ const opened = await spireXSignOpenAsync(
596
684
  XUtils.decodeHex(signed),
597
685
  XUtils.decodeHex(device.signKey),
598
686
  );
@@ -743,13 +831,14 @@ export class Spire extends EventEmitter {
743
831
  return;
744
832
  }
745
833
 
746
- const regKey = xSignOpen(
834
+ const regKey = await spireXSignOpenAsync(
747
835
  XUtils.decodeHex(regPayload.signed),
748
836
  XUtils.decodeHex(regPayload.signKey),
749
837
  );
750
838
 
751
839
  if (
752
840
  regKey &&
841
+ regKey.length === 16 &&
753
842
  this.validateToken(
754
843
  uuidStringify(regKey),
755
844
  TokenScopes.Register,
@@ -800,14 +889,28 @@ export class Spire extends EventEmitter {
800
889
  }
801
890
  res.send(msgpack.encode(censorUser(user)));
802
891
  }
892
+ } else if (regKey && regKey.length !== 16) {
893
+ res.status(400).send({
894
+ error: "Invalid registration token payload.",
895
+ });
803
896
  } else {
804
897
  res.status(400).send({
805
898
  error: "Invalid or no token supplied.",
806
899
  });
807
900
  }
808
- } catch (_err: unknown) {
809
- // debugger: registration error
810
- res.sendStatus(500);
901
+ } catch (err: unknown) {
902
+ const requestId = crypto.randomUUID();
903
+ const message =
904
+ err instanceof Error ? err.message : String(err);
905
+ console.error(
906
+ `[spire] /register failed requestId=${requestId} profile=${this.cryptoProfile} message=${message}`,
907
+ );
908
+ if (err instanceof Error && err.stack) {
909
+ console.error(err.stack);
910
+ }
911
+ res.status(500).json({
912
+ error: `Registration failed. requestId=${requestId}`,
913
+ });
811
914
  }
812
915
  });
813
916
 
@@ -71,7 +71,8 @@ describe("Database", () => {
71
71
  it("takes a userId and one time key, adds a keyId and saves it to oneTimeKey table", async () => {
72
72
  expect.assertions(1);
73
73
 
74
- vi.mocked(uuid.v4).mockReturnValueOnce(keyID);
74
+ // uuid@14 overloads: mock the string return path (v4 with no `buf` argument).
75
+ vi.mocked(uuid.v4 as () => string).mockReturnValueOnce(keyID);
75
76
 
76
77
  const provider = new Database(options);
77
78
  await new Promise<void>((resolve, reject) => {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
+ * Licensed under AGPL-3.0. See LICENSE for details.
4
+ * Commercial licenses available at vex.wtf
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import {
10
+ DEFAULT_SPIRE_API_PORT,
11
+ resolveSpireListenPort,
12
+ } from "../spireListenPort.ts";
13
+
14
+ describe("resolveSpireListenPort", () => {
15
+ it("uses default for tweetnacl and fips when no explicit port", () => {
16
+ expect(resolveSpireListenPort(undefined)).toBe(DEFAULT_SPIRE_API_PORT);
17
+ });
18
+
19
+ it("honors explicit api port", () => {
20
+ expect(resolveSpireListenPort(9000)).toBe(9000);
21
+ });
22
+ });
package/src/index.ts CHANGED
@@ -7,3 +7,7 @@
7
7
  import { Spire } from "./Spire.ts";
8
8
 
9
9
  export { Spire };
10
+ export {
11
+ DEFAULT_SPIRE_API_PORT,
12
+ resolveSpireListenPort,
13
+ } from "./spireListenPort.ts";
package/src/run.ts CHANGED
@@ -9,7 +9,7 @@ import type { SpireOptions } from "./Spire.ts";
9
9
  import { Spire } from "./Spire.ts";
10
10
  import { loadEnv } from "./utils/loadEnv.ts";
11
11
 
12
- function main() {
12
+ async function main() {
13
13
  // load the environment variables — loadEnv() exits if required vars are missing
14
14
  loadEnv();
15
15
 
@@ -18,13 +18,32 @@ function main() {
18
18
  throw new Error("SPK must be set (loadEnv should have caught this).");
19
19
  }
20
20
 
21
- const apiPort = process.env["API_PORT"];
21
+ const rawPort = process.env["API_PORT"]?.trim() ?? "";
22
+ const apiPort =
23
+ rawPort.length > 0 ? Number.parseInt(rawPort, 10) : undefined;
24
+ if (apiPort !== undefined) {
25
+ if (!Number.isFinite(apiPort) || apiPort < 1 || apiPort > 65_535) {
26
+ throw new Error(
27
+ `API_PORT must be 1-65535; got ${JSON.stringify(process.env["API_PORT"])}.`,
28
+ );
29
+ }
30
+ }
22
31
  const dbType = parseDbType(process.env["DB_TYPE"]);
32
+ const fips =
33
+ process.env["SPIRE_FIPS"] === "1" ||
34
+ process.env["SPIRE_FIPS"] === "true";
23
35
 
24
- new Spire(spk, {
25
- ...(apiPort !== undefined ? { apiPort: Number(apiPort) } : {}),
36
+ const options: SpireOptions = {
37
+ ...(apiPort !== undefined ? { apiPort } : {}),
26
38
  ...(dbType !== undefined ? { dbType } : {}),
27
- });
39
+ ...(fips ? { cryptoProfile: "fips" } : {}),
40
+ };
41
+
42
+ if (fips) {
43
+ await Spire.createAsync(spk, options);
44
+ } else {
45
+ new Spire(spk, options);
46
+ }
28
47
  }
29
48
 
30
49
  function parseDbType(value: string | undefined): SpireOptions["dbType"] {
@@ -39,4 +58,7 @@ function parseDbType(value: string | undefined): SpireOptions["dbType"] {
39
58
  }
40
59
  }
41
60
 
42
- main();
61
+ void main().catch((err: unknown) => {
62
+ console.error(err);
63
+ process.exit(1);
64
+ });
@@ -12,8 +12,7 @@ import * as fsp from "node:fs/promises";
12
12
 
13
13
  import express from "express";
14
14
 
15
- import { XUtils } from "@vex-chat/crypto";
16
- import { type KeyPair, xSignOpen } from "@vex-chat/crypto";
15
+ import { type KeyPair, XUtils } from "@vex-chat/crypto";
17
16
  import { PreKeysWSSchema, TokenScopes, UserSchema } from "@vex-chat/types";
18
17
 
19
18
  import cors from "cors";
@@ -29,6 +28,7 @@ import { POWER_LEVELS } from "../ClientManager.ts";
29
28
  import { JWT_EXPIRY } from "../Spire.ts";
30
29
  import { getJwtSecret } from "../utils/jwtSecret.ts";
31
30
  import { msgpack } from "../utils/msgpack.ts";
31
+ import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
32
32
 
33
33
  import { getAvatarRouter } from "./avatar.ts";
34
34
  import { errorHandler } from "./errors.ts";
@@ -181,7 +181,7 @@ export const initApp = (
181
181
  ) => void,
182
182
  ) => {
183
183
  // INIT ROUTERS
184
- const userRouter = getUserRouter(db, tokenValidator);
184
+ const userRouter = getUserRouter(db, tokenValidator, notify);
185
185
  const fileRouter = getFileRouter(db);
186
186
  const avatarRouter = getAvatarRouter();
187
187
  const inviteRouter = getInviteRouter(db, tokenValidator, notify);
@@ -198,16 +198,10 @@ export const initApp = (
198
198
  type: "application/msgpack",
199
199
  }),
200
200
  );
201
- api.use(helmet());
202
- api.use(msgpackParser);
203
- api.use(checkAuth);
204
- api.use(checkDevice);
205
201
 
206
- // Browser clients (web, Tauri, Capacitor, etc.) hit Spire from arbitrary
207
- // origins when self-hosted or embedded in third-party apps. libvex uses
208
- // `Authorization: Bearer` only (no cookies), so reflecting `Origin` is the
209
- // usual bearer-API pattern. Set `CORS_ORIGINS` to a comma-separated allowlist
210
- // when an operator wants to restrict which frontends may call the API.
202
+ // CORS before helmet/auth so browser preflight (OPTIONS) and PATCH get
203
+ // Access-Control-* headers. Node clients ignore CORS; browsers do not.
204
+ // Set `CORS_ORIGINS` to a comma-separated allowlist to restrict frontends.
211
205
  const corsRaw = process.env["CORS_ORIGINS"];
212
206
  const corsOrigins = corsRaw
213
207
  ? corsRaw
@@ -218,6 +212,15 @@ export const initApp = (
218
212
  api.use(
219
213
  cors({
220
214
  credentials: true,
215
+ methods: [
216
+ "GET",
217
+ "HEAD",
218
+ "POST",
219
+ "PUT",
220
+ "PATCH",
221
+ "DELETE",
222
+ "OPTIONS",
223
+ ],
221
224
  origin:
222
225
  corsOrigins.length > 0
223
226
  ? corsOrigins
@@ -225,6 +228,11 @@ export const initApp = (
225
228
  }),
226
229
  );
227
230
 
231
+ api.use(helmet());
232
+ api.use(msgpackParser);
233
+ api.use(checkAuth);
234
+ api.use(checkDevice);
235
+
228
236
  api.get("/server/:id", protect, async (req, res) => {
229
237
  const server = await db.retrieveServer(getParam(req, "id"));
230
238
 
@@ -576,7 +584,10 @@ export const initApp = (
576
584
  return;
577
585
  }
578
586
 
579
- const regKey = xSignOpen(signed, XUtils.decodeHex(device.signKey));
587
+ const regKey = await spireXSignOpenAsync(
588
+ signed,
589
+ XUtils.decodeHex(device.signKey),
590
+ );
580
591
  if (
581
592
  regKey &&
582
593
  tokenValidator(uuidStringify(regKey), TokenScopes.Connect)
@@ -628,7 +639,7 @@ export const initApp = (
628
639
  return;
629
640
  }
630
641
 
631
- const message = xSignOpen(
642
+ const message = await spireXSignOpenAsync(
632
643
  otk.signature,
633
644
  XUtils.decodeHex(device.signKey),
634
645
  );