@vex-chat/spire 1.11.5 → 2.0.1

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 (48) 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/NotificationService.js +3 -4
  9. package/dist/NotificationService.js.map +1 -1
  10. package/dist/Spire.d.ts +3 -0
  11. package/dist/Spire.js +155 -2
  12. package/dist/Spire.js.map +1 -1
  13. package/dist/migrations/2026-05-03_passkeys.js +2 -2
  14. package/dist/migrations/2026-05-03_passkeys.js.map +1 -1
  15. package/dist/server/index.d.ts +2 -2
  16. package/dist/server/index.js +18 -4
  17. package/dist/server/index.js.map +1 -1
  18. package/dist/server/passkey.js +24 -16
  19. package/dist/server/passkey.js.map +1 -1
  20. package/dist/server/passkeyDevices.d.ts +2 -2
  21. package/dist/server/passkeyDevices.js +14 -15
  22. package/dist/server/passkeyDevices.js.map +1 -1
  23. package/dist/server/rateLimit.d.ts +6 -2
  24. package/dist/server/rateLimit.js +13 -10
  25. package/dist/server/rateLimit.js.map +1 -1
  26. package/dist/server/user.d.ts +14 -0
  27. package/dist/server/user.js +48 -0
  28. package/dist/server/user.js.map +1 -1
  29. package/dist/utils/loadEnv.d.ts +3 -0
  30. package/dist/utils/loadEnv.js +63 -3
  31. package/dist/utils/loadEnv.js.map +1 -1
  32. package/package.json +6 -6
  33. package/src/ClientManager.ts +4 -0
  34. package/src/Database.ts +122 -16
  35. package/src/NotificationService.ts +3 -4
  36. package/src/Spire.ts +230 -2
  37. package/src/__tests__/Database.spec.ts +191 -2
  38. package/src/__tests__/loadEnv.spec.ts +91 -0
  39. package/src/__tests__/notifyFanout.spec.ts +44 -0
  40. package/src/__tests__/passkeys.spec.ts +46 -0
  41. package/src/__tests__/rateLimit.spec.ts +60 -0
  42. package/src/migrations/2026-05-03_passkeys.ts +2 -2
  43. package/src/server/index.ts +26 -3
  44. package/src/server/passkey.ts +25 -17
  45. package/src/server/passkeyDevices.ts +17 -14
  46. package/src/server/rateLimit.ts +18 -10
  47. package/src/server/user.ts +66 -0
  48. package/src/utils/loadEnv.ts +79 -3
@@ -10,7 +10,10 @@ import express from "express";
10
10
 
11
11
  import { msgpack } from "../utils/msgpack.ts";
12
12
 
13
- import { resolveDeviceEnrollmentRequest } from "./user.ts";
13
+ import {
14
+ recoverDeviceEnrollmentRequest,
15
+ resolveDeviceEnrollmentRequest,
16
+ } from "./user.ts";
14
17
  import { getParam, getUser } from "./utils.ts";
15
18
 
16
19
  import { protectPasskey } from "./index.ts";
@@ -21,8 +24,8 @@ import { protectPasskey } from "./index.ts";
21
24
  *
22
25
  * - `GET /user/:id/passkey/devices` — list
23
26
  * - `DELETE /user/:id/passkey/devices/:deviceID` — remove
24
- * - `POST /user/:id/passkey/devices/requests/:requestID/approve` — approve
25
27
  * - `POST /user/:id/passkey/devices/requests/:requestID/reject` — reject
28
+ * - `POST /user/:id/passkey/recover/devices/requests/:requestID` — recover
26
29
  *
27
30
  * The route family is parallel to `/user/:id/devices/...` so the
28
31
  * existing device-authenticated flow stays untouched (and there's no
@@ -42,6 +45,7 @@ export const getPasskeyDeviceRouter = (
42
45
  data?: unknown,
43
46
  deviceID?: string,
44
47
  ) => void,
48
+ disconnectDevices?: (deviceIDs: string[]) => void,
45
49
  ) => {
46
50
  const router = express.Router();
47
51
 
@@ -79,10 +83,9 @@ export const getPasskeyDeviceRouter = (
79
83
  // The device-auth `DELETE /user/:id/devices/:deviceID`
80
84
  // refuses to delete the user's last device (a device
81
85
  // can't lock itself out). Passkeys may delete the last
82
- // device on purpose: that's the entire recovery story —
86
+ // device on purpose: that's part of the recovery story —
83
87
  // "I lost my phone, sign in with the passkey, wipe the
84
- // old device, then enroll a new one with the passkey
85
- // standing in as the approver."
88
+ // old device, then recover onto a new one."
86
89
  await db.deleteDevice(deviceID);
87
90
  // Tell whoever's online that the device-list shape
88
91
  // changed; clients use this to refresh the Settings →
@@ -93,7 +96,7 @@ export const getPasskeyDeviceRouter = (
93
96
  );
94
97
 
95
98
  router.post(
96
- "/user/:id/passkey/devices/requests/:requestID/approve",
99
+ "/user/:id/passkey/recover/devices/requests/:requestID",
97
100
  protectPasskey,
98
101
  async (req, res) => {
99
102
  const userDetails = getUser(req);
@@ -103,20 +106,20 @@ export const getPasskeyDeviceRouter = (
103
106
  res.sendStatus(401);
104
107
  return;
105
108
  }
106
- // No second-factor signature here: the passkey JWT itself
107
- // is fresh proof of user presence (5 min TTL, freshly
108
- // minted from a WebAuthn ceremony with userVerification).
109
- // Reusing it within those 5 minutes to approve an
110
- // enrollment is fine the equivalent guarantee that the
111
- // device flow gets from a per-request Ed25519 sig.
112
- const result = await resolveDeviceEnrollmentRequest({
113
- action: "approve",
109
+ // Recovery is intentionally the only passkey-backed
110
+ // provisioning path: it provisions the pending device and
111
+ // revokes every previously-active device for the account in
112
+ // one server-side operation. Clients cannot accidentally
113
+ // restore an account while leaving lost devices trusted.
114
+ const result = await recoverDeviceEnrollmentRequest({
114
115
  db,
115
116
  notify,
116
117
  requestID,
117
118
  userID,
118
119
  });
119
120
  if (result.kind === "ok") {
121
+ notify(userID, "deviceListChanged", crypto.randomUUID());
122
+ disconnectDevices?.(result.revokedDeviceIDs);
120
123
  res.send(msgpack.encode(result.device));
121
124
  return;
122
125
  }
@@ -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
  }