@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.
- package/README.md +9 -7
- package/dist/ClientManager.d.ts +1 -0
- package/dist/ClientManager.js +3 -0
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +11 -0
- package/dist/Database.js +90 -16
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.js +3 -4
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.d.ts +3 -0
- package/dist/Spire.js +155 -2
- package/dist/Spire.js.map +1 -1
- package/dist/migrations/2026-05-03_passkeys.js +2 -2
- package/dist/migrations/2026-05-03_passkeys.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +18 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +24 -16
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.d.ts +2 -2
- package/dist/server/passkeyDevices.js +14 -15
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/rateLimit.d.ts +6 -2
- package/dist/server/rateLimit.js +13 -10
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +14 -0
- package/dist/server/user.js +48 -0
- package/dist/server/user.js.map +1 -1
- package/dist/utils/loadEnv.d.ts +3 -0
- package/dist/utils/loadEnv.js +63 -3
- package/dist/utils/loadEnv.js.map +1 -1
- package/package.json +6 -6
- package/src/ClientManager.ts +4 -0
- package/src/Database.ts +122 -16
- package/src/NotificationService.ts +3 -4
- package/src/Spire.ts +230 -2
- package/src/__tests__/Database.spec.ts +191 -2
- package/src/__tests__/loadEnv.spec.ts +91 -0
- package/src/__tests__/notifyFanout.spec.ts +44 -0
- package/src/__tests__/passkeys.spec.ts +46 -0
- package/src/__tests__/rateLimit.spec.ts +60 -0
- package/src/migrations/2026-05-03_passkeys.ts +2 -2
- package/src/server/index.ts +26 -3
- package/src/server/passkey.ts +25 -17
- package/src/server/passkeyDevices.ts +17 -14
- package/src/server/rateLimit.ts +18 -10
- package/src/server/user.ts +66 -0
- 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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
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
|
}
|
package/src/server/rateLimit.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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";
|
package/src/server/user.ts
CHANGED
|
@@ -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;
|
package/src/utils/loadEnv.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
for (const required of
|
|
14
|
-
if (process.env[required]
|
|
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
|
}
|