@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.
@@ -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
+ };
@@ -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,
@@ -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
  }