@vex-chat/spire 1.3.6 → 1.4.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/dist/Database.d.ts +36 -1
- package/dist/Database.js +109 -0
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +7 -0
- package/dist/Spire.js +7 -0
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +16 -0
- package/dist/migrations/2026-05-03_passkeys.d.ts +8 -0
- package/dist/migrations/2026-05-03_passkeys.js +44 -0
- package/dist/migrations/2026-05-03_passkeys.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.js +52 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.d.ts +7 -0
- package/dist/server/passkey.js +449 -0
- package/dist/server/passkey.js.map +1 -0
- package/dist/server/passkeyDevices.d.ts +25 -0
- package/dist/server/passkeyDevices.js +118 -0
- package/dist/server/passkeyDevices.js.map +1 -0
- package/dist/server/user.d.ts +27 -1
- package/dist/server/user.js +92 -0
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +10 -0
- package/dist/types/express.js.map +1 -1
- package/package.json +6 -5
- package/src/Database.ts +134 -1
- package/src/Spire.ts +7 -0
- package/src/__tests__/passkeys.spec.ts +217 -0
- package/src/db/schema.ts +18 -1
- package/src/migrations/2026-05-03_passkeys.ts +52 -0
- package/src/server/index.ts +55 -6
- package/src/server/passkey.ts +589 -0
- package/src/server/passkeyDevices.ts +154 -0
- package/src/server/user.ts +114 -1
- package/src/types/express.ts +8 -0
|
@@ -0,0 +1,154 @@
|
|
|
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 type { Database } from "../Database.ts";
|
|
8
|
+
|
|
9
|
+
import express from "express";
|
|
10
|
+
|
|
11
|
+
import { msgpack } from "../utils/msgpack.ts";
|
|
12
|
+
|
|
13
|
+
import { resolveDeviceEnrollmentRequest } from "./user.ts";
|
|
14
|
+
import { getParam, getUser } from "./utils.ts";
|
|
15
|
+
|
|
16
|
+
import { protectPasskey } from "./index.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Routes that grant a passkey-authenticated session a strictly
|
|
20
|
+
* bounded admin/recovery surface:
|
|
21
|
+
*
|
|
22
|
+
* - `GET /user/:id/passkey/devices` — list
|
|
23
|
+
* - `DELETE /user/:id/passkey/devices/:deviceID` — remove
|
|
24
|
+
* - `POST /user/:id/passkey/devices/requests/:requestID/approve` — approve
|
|
25
|
+
* - `POST /user/:id/passkey/devices/requests/:requestID/reject` — reject
|
|
26
|
+
*
|
|
27
|
+
* The route family is parallel to `/user/:id/devices/...` so the
|
|
28
|
+
* existing device-authenticated flow stays untouched (and there's no
|
|
29
|
+
* confusion about which kind of credential is doing what when a
|
|
30
|
+
* single endpoint accepts both).
|
|
31
|
+
*
|
|
32
|
+
* Mail/server/permissions/etc. routes are intentionally NOT mirrored
|
|
33
|
+
* here — passkeys are an administrative credential, not a messaging
|
|
34
|
+
* device.
|
|
35
|
+
*/
|
|
36
|
+
export const getPasskeyDeviceRouter = (
|
|
37
|
+
db: Database,
|
|
38
|
+
notify: (
|
|
39
|
+
userID: string,
|
|
40
|
+
event: string,
|
|
41
|
+
transmissionID: string,
|
|
42
|
+
data?: unknown,
|
|
43
|
+
deviceID?: string,
|
|
44
|
+
) => void,
|
|
45
|
+
) => {
|
|
46
|
+
const router = express.Router();
|
|
47
|
+
|
|
48
|
+
router.get(
|
|
49
|
+
"/user/:id/passkey/devices",
|
|
50
|
+
protectPasskey,
|
|
51
|
+
async (req, res) => {
|
|
52
|
+
const userDetails = getUser(req);
|
|
53
|
+
const userID = getParam(req, "id");
|
|
54
|
+
if (userDetails.userID !== userID) {
|
|
55
|
+
res.sendStatus(401);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const list = await db.retrieveUserDeviceList([userID]);
|
|
59
|
+
res.send(msgpack.encode(list));
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
router.delete(
|
|
64
|
+
"/user/:id/passkey/devices/:deviceID",
|
|
65
|
+
protectPasskey,
|
|
66
|
+
async (req, res) => {
|
|
67
|
+
const userDetails = getUser(req);
|
|
68
|
+
const userID = getParam(req, "id");
|
|
69
|
+
const deviceID = getParam(req, "deviceID");
|
|
70
|
+
if (userDetails.userID !== userID) {
|
|
71
|
+
res.sendStatus(401);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const device = await db.retrieveDevice(deviceID);
|
|
75
|
+
if (!device || device.owner !== userID) {
|
|
76
|
+
res.sendStatus(404);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// The device-auth `DELETE /user/:id/devices/:deviceID`
|
|
80
|
+
// refuses to delete the user's last device (a device
|
|
81
|
+
// can't lock itself out). Passkeys may delete the last
|
|
82
|
+
// device on purpose: that's the entire recovery story —
|
|
83
|
+
// "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."
|
|
86
|
+
await db.deleteDevice(deviceID);
|
|
87
|
+
// Tell whoever's online that the device-list shape
|
|
88
|
+
// changed; clients use this to refresh the Settings →
|
|
89
|
+
// Devices view in real time.
|
|
90
|
+
notify(userID, "deviceListChanged", crypto.randomUUID());
|
|
91
|
+
res.sendStatus(200);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
router.post(
|
|
96
|
+
"/user/:id/passkey/devices/requests/:requestID/approve",
|
|
97
|
+
protectPasskey,
|
|
98
|
+
async (req, res) => {
|
|
99
|
+
const userDetails = getUser(req);
|
|
100
|
+
const userID = getParam(req, "id");
|
|
101
|
+
const requestID = getParam(req, "requestID");
|
|
102
|
+
if (userDetails.userID !== userID) {
|
|
103
|
+
res.sendStatus(401);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
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",
|
|
114
|
+
db,
|
|
115
|
+
notify,
|
|
116
|
+
requestID,
|
|
117
|
+
userID,
|
|
118
|
+
});
|
|
119
|
+
if (result.kind === "ok") {
|
|
120
|
+
res.send(msgpack.encode(result.device));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
res.status(result.status).send({ error: result.error });
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
router.post(
|
|
128
|
+
"/user/:id/passkey/devices/requests/:requestID/reject",
|
|
129
|
+
protectPasskey,
|
|
130
|
+
async (req, res) => {
|
|
131
|
+
const userDetails = getUser(req);
|
|
132
|
+
const userID = getParam(req, "id");
|
|
133
|
+
const requestID = getParam(req, "requestID");
|
|
134
|
+
if (userDetails.userID !== userID) {
|
|
135
|
+
res.sendStatus(401);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const result = await resolveDeviceEnrollmentRequest({
|
|
139
|
+
action: "reject",
|
|
140
|
+
db,
|
|
141
|
+
notify,
|
|
142
|
+
requestID,
|
|
143
|
+
userID,
|
|
144
|
+
});
|
|
145
|
+
if (result.kind === "ok") {
|
|
146
|
+
res.sendStatus(200);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
res.status(result.status).send({ error: result.error });
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return router;
|
|
154
|
+
};
|
package/src/server/user.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Database } from "../Database.ts";
|
|
8
|
-
import type { DevicePayload } from "@vex-chat/types";
|
|
8
|
+
import type { Device, DevicePayload } from "@vex-chat/types";
|
|
9
9
|
|
|
10
10
|
import express from "express";
|
|
11
11
|
|
|
@@ -65,6 +65,7 @@ export function createPendingDeviceEnrollmentRequest(
|
|
|
65
65
|
expiresAt: string;
|
|
66
66
|
requestID: string;
|
|
67
67
|
status: "pending_approval";
|
|
68
|
+
userID: string;
|
|
68
69
|
} {
|
|
69
70
|
pruneDeviceEnrollmentRequests();
|
|
70
71
|
const requestID = crypto.randomUUID();
|
|
@@ -82,6 +83,13 @@ export function createPendingDeviceEnrollmentRequest(
|
|
|
82
83
|
requestID,
|
|
83
84
|
status: "pending",
|
|
84
85
|
});
|
|
86
|
+
// We include the existing user's `userID` so the new (still-
|
|
87
|
+
// unauthenticated) device can fetch its public avatar from the
|
|
88
|
+
// unauthenticated `/avatar/:userID` endpoint and surface an "is
|
|
89
|
+
// this you?" confirmation before continuing with the approval
|
|
90
|
+
// request. The userID is already implicit (the requester typed the
|
|
91
|
+
// username and learned the account exists from this very response),
|
|
92
|
+
// so returning it here doesn't expand the attack surface.
|
|
85
93
|
return {
|
|
86
94
|
challenge: challengeHex,
|
|
87
95
|
expiresAt: new Date(
|
|
@@ -89,9 +97,114 @@ export function createPendingDeviceEnrollmentRequest(
|
|
|
89
97
|
).toISOString(),
|
|
90
98
|
requestID,
|
|
91
99
|
status: "pending_approval",
|
|
100
|
+
userID,
|
|
92
101
|
};
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Reusable approve/reject side of a pending device-enrollment
|
|
106
|
+
* request. Lives here so both the device-authenticated router
|
|
107
|
+
* (signs an approval challenge with the approving device's
|
|
108
|
+
* Ed25519 key) and the passkey-authenticated router (relies on
|
|
109
|
+
* the freshness of the passkey JWT instead) can share the same
|
|
110
|
+
* state-machine + enrollment lifecycle.
|
|
111
|
+
*
|
|
112
|
+
* The device-auth caller is expected to have already verified the
|
|
113
|
+
* approving device's signature before invoking this helper.
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveDeviceEnrollmentRequest(args: {
|
|
116
|
+
action: "approve" | "reject";
|
|
117
|
+
db: Database;
|
|
118
|
+
notify: (
|
|
119
|
+
userID: string,
|
|
120
|
+
event: string,
|
|
121
|
+
transmissionID: string,
|
|
122
|
+
data?: unknown,
|
|
123
|
+
deviceID?: string,
|
|
124
|
+
) => void;
|
|
125
|
+
requestID: string;
|
|
126
|
+
userID: string;
|
|
127
|
+
}): Promise<
|
|
128
|
+
| { device: Device; kind: "ok" }
|
|
129
|
+
| { error: string; kind: "err"; status: number }
|
|
130
|
+
> {
|
|
131
|
+
pruneDeviceEnrollmentRequests();
|
|
132
|
+
const pending = deviceEnrollments.get(args.requestID);
|
|
133
|
+
if (!pending || pending.userID !== args.userID) {
|
|
134
|
+
return { error: "Request not found.", kind: "err", status: 404 };
|
|
135
|
+
}
|
|
136
|
+
if (pending.status !== "pending") {
|
|
137
|
+
return {
|
|
138
|
+
error: "Request is not pending.",
|
|
139
|
+
kind: "err",
|
|
140
|
+
status: 409,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (Date.now() - pending.createdAt > DEVICE_REQUEST_TTL_MS) {
|
|
144
|
+
pending.status = "expired";
|
|
145
|
+
pending.resolvedAt = Date.now();
|
|
146
|
+
pending.error = "Request expired.";
|
|
147
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
148
|
+
return { error: "Request expired.", kind: "err", status: 410 };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (args.action === "reject") {
|
|
152
|
+
pending.status = "rejected";
|
|
153
|
+
pending.resolvedAt = Date.now();
|
|
154
|
+
pending.error = "Rejected.";
|
|
155
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
156
|
+
args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
|
|
157
|
+
requestID: args.requestID,
|
|
158
|
+
status: "rejected",
|
|
159
|
+
});
|
|
160
|
+
// Caller maps `kind: "ok"` without device to a 200; reuse the
|
|
161
|
+
// ok shape with a placeholder device since the type insists
|
|
162
|
+
// on one. The reject flow doesn't return a body, the caller
|
|
163
|
+
// discards `device`.
|
|
164
|
+
return {
|
|
165
|
+
device: {
|
|
166
|
+
deleted: false,
|
|
167
|
+
deviceID: "",
|
|
168
|
+
lastLogin: "",
|
|
169
|
+
name: "",
|
|
170
|
+
owner: args.userID,
|
|
171
|
+
signKey: "",
|
|
172
|
+
},
|
|
173
|
+
kind: "ok",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const device = await args.db.createDevice(
|
|
179
|
+
args.userID,
|
|
180
|
+
pending.devicePayload,
|
|
181
|
+
);
|
|
182
|
+
pending.status = "approved";
|
|
183
|
+
pending.approvedDeviceID = device.deviceID;
|
|
184
|
+
pending.resolvedAt = Date.now();
|
|
185
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
186
|
+
args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
|
|
187
|
+
requestID: args.requestID,
|
|
188
|
+
status: "approved",
|
|
189
|
+
});
|
|
190
|
+
return { device, kind: "ok" };
|
|
191
|
+
} catch {
|
|
192
|
+
pending.status = "rejected";
|
|
193
|
+
pending.error = "Could not create approved device.";
|
|
194
|
+
pending.resolvedAt = Date.now();
|
|
195
|
+
deviceEnrollments.set(args.requestID, pending);
|
|
196
|
+
args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
|
|
197
|
+
requestID: args.requestID,
|
|
198
|
+
status: "rejected",
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
error: "Could not create approved device.",
|
|
202
|
+
kind: "err",
|
|
203
|
+
status: 470,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
95
208
|
function buildApprovalChallenge(
|
|
96
209
|
requestID: string,
|
|
97
210
|
signKey: string,
|
package/src/types/express.ts
CHANGED
|
@@ -21,6 +21,14 @@ declare global {
|
|
|
21
21
|
bearerToken?: string;
|
|
22
22
|
device?: Device;
|
|
23
23
|
exp?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Set by `checkPasskey` middleware when the bearer token is
|
|
26
|
+
* a passkey-scoped JWT. The presence of `req.passkey`
|
|
27
|
+
* (without `req.device`) marks an admin-only request that
|
|
28
|
+
* may list/delete devices and approve enrollments, but
|
|
29
|
+
* cannot send mail or do anything device-specific.
|
|
30
|
+
*/
|
|
31
|
+
passkey?: { passkeyID: string };
|
|
24
32
|
user?: User;
|
|
25
33
|
}
|
|
26
34
|
}
|