@vex-chat/spire 1.0.4 → 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
@@ -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,
@@ -41,13 +42,15 @@ import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
41
42
  import { initApp, protect } from "./server/index.ts";
42
43
  import { authLimiter, devApiKeySkipsRateLimits } from "./server/rateLimit.ts";
43
44
  import { censorUser, getParam, getUser } from "./server/utils.ts";
45
+ import { resolveSpireListenPort } from "./spireListenPort.ts";
44
46
  import { getJwtSecret } from "./utils/jwtSecret.ts";
45
47
  import { msgpack } from "./utils/msgpack.ts";
48
+ import { spireXSignOpenAsync } from "./utils/spireXSignOpenAsync.ts";
46
49
 
47
50
  // expiry of regkeys = 24hr
48
51
  export const TOKEN_EXPIRY = 1000 * 60 * 10;
49
52
  export const JWT_EXPIRY = "7d";
50
- export const DEVICE_AUTH_JWT_EXPIRY = "1h";
53
+ export const DEVICE_AUTH_JWT_EXPIRY = "7d";
51
54
  const DEVICE_CHALLENGE_EXPIRY = 1000 * 60; // 60 seconds
52
55
 
53
56
  // 3-19 chars long
@@ -132,24 +135,41 @@ const getCommitSha = (): string => {
132
135
  }
133
136
  };
134
137
 
135
- /** Hyphenated UUIDs in paths/query — replaced so request logs are less identifying. */
136
- function redactUuidsForLog(url: string): string {
137
- return url.replace(
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
+ */
143
+ apiPort?: number;
144
+ /** Default `tweetnacl`. For `fips`, use `Spire.createAsync` (FIPS key load is async). */
145
+ cryptoProfile?: "fips" | "tweetnacl";
146
+ dbType?: "mysql" | "sqlite3" | "sqlite3mem" | "sqlite";
147
+ }
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(
138
156
  /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
139
157
  "[uuid]",
140
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;
141
162
  }
142
163
 
143
- export interface SpireOptions {
144
- apiPort?: number;
145
- dbType?: "mysql" | "sqlite3" | "sqlite3mem" | "sqlite";
146
- }
164
+ /** FIPS: sign key loaded inside `Spire.createAsync` before the constructor runs. */
165
+ let pendingFipsKeyPair: KeyPair | null = null;
147
166
 
148
167
  export class Spire extends EventEmitter {
149
168
  private actionTokens: ActionToken[] = [];
150
169
  private api = express();
151
170
  private clients: ClientManager[] = [];
152
171
  private readonly commitSha = getCommitSha();
172
+ private readonly cryptoProfile: "fips" | "tweetnacl";
153
173
  private db: Database;
154
174
  private dbReady = false;
155
175
  private deviceChallenges = new Map<
@@ -157,13 +177,13 @@ export class Spire extends EventEmitter {
157
177
  { deviceID: string; nonce: string; time: number }
158
178
  >();
159
179
  private queuedRequestIncrements = 0;
180
+
160
181
  private requestsTotal = 0;
161
182
 
162
183
  private requestsTotalLoaded = false;
163
-
164
184
  private server: null | Server = null;
165
- private signKeys: KeyPair;
166
185
 
186
+ private signKeys: KeyPair;
167
187
  private readonly startedAt = new Date();
168
188
  private readonly version = getAppVersion();
169
189
  private wss: WebSocketServer = new WebSocketServer({
@@ -173,7 +193,19 @@ export class Spire extends EventEmitter {
173
193
 
174
194
  constructor(SK: string, options?: SpireOptions) {
175
195
  super();
176
- 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
+ }
177
209
 
178
210
  // Trust a single proxy hop (nginx / cloudflare / load balancer).
179
211
  // Required so `req.ip` and `express-rate-limit`'s keyGenerator see
@@ -190,7 +222,34 @@ export class Spire extends EventEmitter {
190
222
  });
191
223
  });
192
224
 
193
- 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
+ }
194
253
  }
195
254
 
196
255
  public async close(): Promise<void> {
@@ -241,13 +300,20 @@ export class Spire extends EventEmitter {
241
300
  }
242
301
 
243
302
  private init(apiPort: number): void {
244
- // Request traces (UUIDs redacted in `url` token). Enabled in all envs,
245
- // including production / Docker dependency is non-dev.
246
- morgan.token("url", (req: IncomingMessage) => {
247
- const r = req as IncomingMessage & { originalUrl?: string };
248
- return redactUuidsForLog(r.originalUrl ?? r.url ?? "");
249
- });
250
- this.api.use(morgan("dev"));
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
+ }
251
317
 
252
318
  this.api.use((_req, _res, next) => {
253
319
  this.requestsTotal += 1;
@@ -456,7 +522,7 @@ export class Spire extends EventEmitter {
456
522
 
457
523
  const ok = dbHealthy;
458
524
  if (!devApiKeySkipsRateLimits(req)) {
459
- res.json({ ok });
525
+ res.json({ cryptoProfile: this.cryptoProfile, ok });
460
526
  return;
461
527
  }
462
528
  const canaryEnv = process.env["CANARY"]?.trim().toLowerCase();
@@ -466,6 +532,7 @@ export class Spire extends EventEmitter {
466
532
  canaryEnv === "true" ||
467
533
  canaryEnv === "yes",
468
534
  checkDurationMs,
535
+ cryptoProfile: this.cryptoProfile,
469
536
  now: new Date(),
470
537
  ok,
471
538
  version: this.version,
@@ -613,7 +680,7 @@ export class Spire extends EventEmitter {
613
680
  }
614
681
 
615
682
  // Verify the Ed25519 signature
616
- const opened = xSignOpen(
683
+ const opened = await spireXSignOpenAsync(
617
684
  XUtils.decodeHex(signed),
618
685
  XUtils.decodeHex(device.signKey),
619
686
  );
@@ -764,13 +831,14 @@ export class Spire extends EventEmitter {
764
831
  return;
765
832
  }
766
833
 
767
- const regKey = xSignOpen(
834
+ const regKey = await spireXSignOpenAsync(
768
835
  XUtils.decodeHex(regPayload.signed),
769
836
  XUtils.decodeHex(regPayload.signKey),
770
837
  );
771
838
 
772
839
  if (
773
840
  regKey &&
841
+ regKey.length === 16 &&
774
842
  this.validateToken(
775
843
  uuidStringify(regKey),
776
844
  TokenScopes.Register,
@@ -821,14 +889,28 @@ export class Spire extends EventEmitter {
821
889
  }
822
890
  res.send(msgpack.encode(censorUser(user)));
823
891
  }
892
+ } else if (regKey && regKey.length !== 16) {
893
+ res.status(400).send({
894
+ error: "Invalid registration token payload.",
895
+ });
824
896
  } else {
825
897
  res.status(400).send({
826
898
  error: "Invalid or no token supplied.",
827
899
  });
828
900
  }
829
- } catch (_err: unknown) {
830
- // debugger: registration error
831
- 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
+ });
832
914
  }
833
915
  });
834
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);
@@ -584,7 +584,10 @@ export const initApp = (
584
584
  return;
585
585
  }
586
586
 
587
- const regKey = xSignOpen(signed, XUtils.decodeHex(device.signKey));
587
+ const regKey = await spireXSignOpenAsync(
588
+ signed,
589
+ XUtils.decodeHex(device.signKey),
590
+ );
588
591
  if (
589
592
  regKey &&
590
593
  tokenValidator(uuidStringify(regKey), TokenScopes.Connect)
@@ -636,7 +639,7 @@ export const initApp = (
636
639
  return;
637
640
  }
638
641
 
639
- const message = xSignOpen(
642
+ const message = await spireXSignOpenAsync(
640
643
  otk.signature,
641
644
  XUtils.decodeHex(device.signKey),
642
645
  );