@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/README.md +43 -35
- package/dist/ClientManager.js +2 -2
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +14 -16
- package/dist/Database.js +74 -101
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +13 -1
- package/dist/Spire.js +83 -20
- package/dist/Spire.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/run.js +24 -6
- package/dist/run.js.map +1 -1
- package/dist/server/index.js +4 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/user.d.ts +1 -1
- package/dist/server/user.js +238 -11
- package/dist/server/user.js.map +1 -1
- package/dist/spireListenPort.d.ts +14 -0
- package/dist/spireListenPort.js +22 -0
- package/dist/spireListenPort.js.map +1 -0
- package/dist/utils/spireXSignOpenAsync.d.ts +9 -0
- package/dist/utils/spireXSignOpenAsync.js +16 -0
- package/dist/utils/spireXSignOpenAsync.js.map +1 -0
- package/package.json +110 -121
- package/src/ClientManager.ts +2 -2
- package/src/Database.ts +85 -116
- package/src/Spire.ts +108 -26
- package/src/__tests__/Database.spec.ts +2 -1
- package/src/__tests__/spireListenPort.spec.ts +22 -0
- package/src/index.ts +4 -0
- package/src/run.ts +28 -6
- package/src/server/index.ts +8 -5
- package/src/server/user.ts +313 -10
- package/src/spireListenPort.ts +25 -0
- package/src/utils/spireXSignOpenAsync.ts +20 -0
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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.
|
|
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
|
|
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
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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 =
|
|
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 =
|
|
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 (
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
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
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
|
|
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
|
-
|
|
25
|
-
...(apiPort !== undefined ? { 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
|
+
});
|
package/src/server/index.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
642
|
+
const message = await spireXSignOpenAsync(
|
|
640
643
|
otk.signature,
|
|
641
644
|
XUtils.decodeHex(device.signKey),
|
|
642
645
|
);
|