@vex-chat/spire 1.11.5 → 2.0.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.
Files changed (44) hide show
  1. package/README.md +9 -7
  2. package/dist/ClientManager.d.ts +1 -0
  3. package/dist/ClientManager.js +3 -0
  4. package/dist/ClientManager.js.map +1 -1
  5. package/dist/Database.d.ts +11 -0
  6. package/dist/Database.js +90 -16
  7. package/dist/Database.js.map +1 -1
  8. package/dist/Spire.d.ts +3 -0
  9. package/dist/Spire.js +155 -2
  10. package/dist/Spire.js.map +1 -1
  11. package/dist/migrations/2026-05-03_passkeys.js +2 -2
  12. package/dist/migrations/2026-05-03_passkeys.js.map +1 -1
  13. package/dist/server/index.d.ts +2 -2
  14. package/dist/server/index.js +18 -4
  15. package/dist/server/index.js.map +1 -1
  16. package/dist/server/passkey.js +24 -16
  17. package/dist/server/passkey.js.map +1 -1
  18. package/dist/server/passkeyDevices.d.ts +2 -2
  19. package/dist/server/passkeyDevices.js +14 -15
  20. package/dist/server/passkeyDevices.js.map +1 -1
  21. package/dist/server/rateLimit.d.ts +6 -2
  22. package/dist/server/rateLimit.js +13 -10
  23. package/dist/server/rateLimit.js.map +1 -1
  24. package/dist/server/user.d.ts +14 -0
  25. package/dist/server/user.js +48 -0
  26. package/dist/server/user.js.map +1 -1
  27. package/dist/utils/loadEnv.d.ts +3 -0
  28. package/dist/utils/loadEnv.js +63 -3
  29. package/dist/utils/loadEnv.js.map +1 -1
  30. package/package.json +5 -5
  31. package/src/ClientManager.ts +4 -0
  32. package/src/Database.ts +122 -16
  33. package/src/Spire.ts +230 -2
  34. package/src/__tests__/Database.spec.ts +191 -2
  35. package/src/__tests__/loadEnv.spec.ts +91 -0
  36. package/src/__tests__/passkeys.spec.ts +46 -0
  37. package/src/__tests__/rateLimit.spec.ts +60 -0
  38. package/src/migrations/2026-05-03_passkeys.ts +2 -2
  39. package/src/server/index.ts +26 -3
  40. package/src/server/passkey.ts +25 -17
  41. package/src/server/passkeyDevices.ts +17 -14
  42. package/src/server/rateLimit.ts +18 -10
  43. package/src/server/user.ts +66 -0
  44. package/src/utils/loadEnv.ts +79 -3
@@ -13,16 +13,11 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit";
13
13
  /** HTTP header carrying the dev API key (must match {@link process.env.DEV_API_KEY}). */
14
14
  export const DEV_API_KEY_HEADER = "x-dev-api-key";
15
15
 
16
- /**
17
- * When `DEV_API_KEY` is set in the environment, any request whose
18
- * `x-dev-api-key` header matches (constant-time) skips all in-process rate
19
- * limiters. Dev / load-testing escape hatch only — never set in production.
20
- * (Future: first-class API keys with scopes may reuse this header name.)
21
- */
22
- export function devApiKeySkipsRateLimits(req: Request): boolean {
23
- if (disableRateLimitsByEnv()) {
24
- return true;
25
- }
16
+ interface HeaderReader {
17
+ get(name: string): string | undefined;
18
+ }
19
+
20
+ export function devApiKeyMatches(req: HeaderReader): boolean {
26
21
  const configured = process.env["DEV_API_KEY"]?.trim() ?? "";
27
22
  if (configured.length === 0) {
28
23
  return false;
@@ -41,6 +36,19 @@ export function devApiKeySkipsRateLimits(req: Request): boolean {
41
36
  }
42
37
  }
43
38
 
39
+ /**
40
+ * When `DEV_API_KEY` is set in the environment, any request whose
41
+ * `x-dev-api-key` header matches (constant-time) skips all in-process rate
42
+ * limiters. Dev / load-testing escape hatch only — never set in production.
43
+ * (Future: first-class API keys with scopes may reuse this header name.)
44
+ */
45
+ export function devApiKeySkipsRateLimits(req: HeaderReader): boolean {
46
+ if (disableRateLimitsByEnv()) {
47
+ return true;
48
+ }
49
+ return devApiKeyMatches(req);
50
+ }
51
+
44
52
  function disableRateLimitsByEnv(): boolean {
45
53
  const raw = process.env["SPIRE_DISABLE_RATE_LIMITS"]?.trim().toLowerCase();
46
54
  return raw === "1" || raw === "true";
@@ -126,6 +126,72 @@ export function createPendingDeviceEnrollmentRequest(
126
126
  * The device-auth caller is expected to have already verified the
127
127
  * approving device's signature before invoking this helper.
128
128
  */
129
+ export async function recoverDeviceEnrollmentRequest(args: {
130
+ db: Database;
131
+ notify: (
132
+ userID: string,
133
+ event: string,
134
+ transmissionID: string,
135
+ data?: unknown,
136
+ deviceID?: string,
137
+ ) => void;
138
+ requestID: string;
139
+ userID: string;
140
+ }): Promise<
141
+ | { device: Device; kind: "ok"; revokedDeviceIDs: string[] }
142
+ | { error: string; kind: "err"; status: number }
143
+ > {
144
+ pruneDeviceEnrollmentRequests();
145
+ const pending = deviceEnrollments.get(args.requestID);
146
+ if (!pending || pending.userID !== args.userID) {
147
+ return { error: "Request not found.", kind: "err", status: 404 };
148
+ }
149
+ if (pending.status !== "pending") {
150
+ return {
151
+ error: "Request is not pending.",
152
+ kind: "err",
153
+ status: 409,
154
+ };
155
+ }
156
+ if (Date.now() - pending.createdAt > DEVICE_REQUEST_TTL_MS) {
157
+ pending.status = "expired";
158
+ pending.resolvedAt = Date.now();
159
+ pending.error = "Request expired.";
160
+ deviceEnrollments.set(args.requestID, pending);
161
+ return { error: "Request expired.", kind: "err", status: 410 };
162
+ }
163
+
164
+ try {
165
+ const { device, revokedDeviceIDs } = await args.db.recoverDevice(
166
+ args.userID,
167
+ pending.devicePayload,
168
+ );
169
+ pending.status = "approved";
170
+ pending.approvedDeviceID = device.deviceID;
171
+ pending.resolvedAt = Date.now();
172
+ deviceEnrollments.set(args.requestID, pending);
173
+ args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
174
+ requestID: args.requestID,
175
+ status: "approved",
176
+ });
177
+ return { device, kind: "ok", revokedDeviceIDs };
178
+ } catch {
179
+ pending.status = "rejected";
180
+ pending.error = "Could not recover device.";
181
+ pending.resolvedAt = Date.now();
182
+ deviceEnrollments.set(args.requestID, pending);
183
+ args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
184
+ requestID: args.requestID,
185
+ status: "rejected",
186
+ });
187
+ return {
188
+ error: "Could not recover device.",
189
+ kind: "err",
190
+ status: 470,
191
+ };
192
+ }
193
+ }
194
+
129
195
  export async function resolveDeviceEnrollmentRequest(args: {
130
196
  action: "approve" | "reject";
131
197
  db: Database;
@@ -6,16 +6,92 @@
6
6
 
7
7
  import { config } from "dotenv";
8
8
 
9
+ const REQUIRED_ENV_VARS = ["DB_TYPE", "JWT_SECRET", "SPK"] as const;
10
+ const NORMALIZED_ENV_VARS = [
11
+ ...REQUIRED_ENV_VARS,
12
+ "API_PORT",
13
+ "SPIRE_FIPS",
14
+ "SPIRE_PASSKEY_RP_ID",
15
+ "SPIRE_PASSKEY_RP_NAME",
16
+ "SPIRE_PASSKEY_ORIGINS",
17
+ "SPIRE_PASSKEY_IOS_APP_IDS",
18
+ "SPIRE_PASSKEY_ANDROID_PACKAGE",
19
+ "SPIRE_PASSKEY_ANDROID_FINGERPRINTS",
20
+ ] as const;
21
+ const HEX_BYTES_RE = /^(?:[0-9a-fA-F]{2})+$/;
22
+ const TWEETNACL_SPK_HEX_LENGTH = 128;
23
+
24
+ export function isSpireFipsEnabled(value: string | undefined): boolean {
25
+ const normalized = value == null ? "" : normalizeEnvValue(value);
26
+ return normalized === "1" || normalized === "true";
27
+ }
28
+
9
29
  /* Populate process.env with vars from .env and verify required vars are present. */
10
30
  export function loadEnv(): void {
11
31
  config();
12
- const requiredEnvVars: string[] = ["DB_TYPE", "JWT_SECRET", "SPK"];
13
- for (const required of requiredEnvVars) {
14
- if (process.env[required] === undefined) {
32
+ normalizeConfiguredEnv();
33
+ for (const required of REQUIRED_ENV_VARS) {
34
+ if (!process.env[required]) {
15
35
  process.stderr.write(
16
36
  `Required environment variable '${required}' is not set. Please consult the README.\n`,
17
37
  );
18
38
  process.exit(1);
19
39
  }
20
40
  }
41
+ validateSpireRuntimeEnv(process.env);
42
+ }
43
+
44
+ export function normalizeEnvValue(value: string): string {
45
+ const trimmed = value.trim();
46
+ if (trimmed.length < 2) {
47
+ return trimmed;
48
+ }
49
+ const first = trimmed[0];
50
+ const last = trimmed[trimmed.length - 1];
51
+ if ((first === `"` && last === `"`) || (first === `'` && last === `'`)) {
52
+ return trimmed.slice(1, -1);
53
+ }
54
+ return trimmed;
55
+ }
56
+
57
+ export function validateSpireRuntimeEnv(
58
+ env: Record<string, string | undefined>,
59
+ ): void {
60
+ const spk = normalizeEnvValue(env["SPK"] ?? "");
61
+ const jwtSecret = normalizeEnvValue(env["JWT_SECRET"] ?? "");
62
+ if (!HEX_BYTES_RE.test(spk)) {
63
+ throw new Error(
64
+ "SPK must be an even-length hex string. Generate one with `pnpm --filter @vex-chat/spire gen-spk` or `gen-spk-fips`.",
65
+ );
66
+ }
67
+
68
+ if (isSpireFipsEnabled(env["SPIRE_FIPS"])) {
69
+ if (spk.length === TWEETNACL_SPK_HEX_LENGTH) {
70
+ throw new Error(
71
+ "SPIRE_FIPS=true requires an SPK from `pnpm --filter @vex-chat/spire gen-spk-fips`; the configured SPK looks like a tweetnacl key.",
72
+ );
73
+ }
74
+ } else if (spk.length !== TWEETNACL_SPK_HEX_LENGTH) {
75
+ throw new Error(
76
+ "SPIRE_FIPS is not enabled, so SPK must be 128 hex characters from `pnpm --filter @vex-chat/spire gen-spk`.",
77
+ );
78
+ }
79
+
80
+ if (jwtSecret.length === 0) {
81
+ throw new Error(
82
+ "JWT_SECRET must be set. Generate one with `pnpm --filter @vex-chat/spire gen-spk` or `gen-spk-fips`.",
83
+ );
84
+ }
85
+ if (jwtSecret === spk) {
86
+ throw new Error("JWT_SECRET must be separate from SPK.");
87
+ }
88
+ }
89
+
90
+ function normalizeConfiguredEnv(): void {
91
+ for (const name of NORMALIZED_ENV_VARS) {
92
+ const value = process.env[name];
93
+ if (value != null) {
94
+ process.env[name] = normalizeEnvValue(value);
95
+ }
96
+ }
21
97
  }