@vex-chat/spire 1.0.3 → 1.1.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.
@@ -5,24 +5,106 @@
5
5
  */
6
6
 
7
7
  import type { Database } from "../Database.ts";
8
+ import type { DevicePayload } from "@vex-chat/types";
8
9
 
9
10
  import express from "express";
10
11
 
12
+ import { xRandomBytes } from "@vex-chat/crypto";
11
13
  import { XUtils } from "@vex-chat/crypto";
12
- import { xSignOpen } from "@vex-chat/crypto";
13
14
  import { DevicePayloadSchema, TokenScopes } from "@vex-chat/types";
14
15
 
15
16
  import { stringify } from "uuid";
17
+ import { z } from "zod/v4";
16
18
 
17
19
  import { msgpack } from "../utils/msgpack.ts";
20
+ import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
18
21
 
19
22
  import { censorUser, getParam, getUser } from "./utils.ts";
20
23
 
21
24
  import { protect } from "./index.ts";
22
25
 
26
+ const DEVICE_REQUEST_TTL_MS = 10 * 60 * 1000;
27
+ const RESOLVED_REQUEST_TTL_MS = 30 * 60 * 1000;
28
+
29
+ interface DeviceEnrollmentRequest {
30
+ approvedDeviceID?: string;
31
+ challengeHex: string;
32
+ createdAt: number;
33
+ devicePayload: DevicePayload;
34
+ error?: string;
35
+ requestID: string;
36
+ resolvedAt?: number;
37
+ status: DeviceEnrollmentStatus;
38
+ userID: string;
39
+ }
40
+
41
+ type DeviceEnrollmentStatus = "approved" | "expired" | "pending" | "rejected";
42
+
43
+ const approvePayloadSchema = z.object({
44
+ signed: z.string().min(1),
45
+ });
46
+
47
+ const deviceEnrollments = new Map<string, DeviceEnrollmentRequest>();
48
+
49
+ function pruneDeviceEnrollmentRequests(nowMs = Date.now()): void {
50
+ for (const [requestID, req] of deviceEnrollments.entries()) {
51
+ if (
52
+ req.status === "pending" &&
53
+ nowMs - req.createdAt > DEVICE_REQUEST_TTL_MS
54
+ ) {
55
+ req.status = "expired";
56
+ req.resolvedAt = nowMs;
57
+ deviceEnrollments.set(requestID, req);
58
+ continue;
59
+ }
60
+ if (
61
+ req.status !== "pending" &&
62
+ req.resolvedAt !== undefined &&
63
+ nowMs - req.resolvedAt > RESOLVED_REQUEST_TTL_MS
64
+ ) {
65
+ deviceEnrollments.delete(requestID);
66
+ }
67
+ }
68
+ }
69
+
70
+ function requestSummary(req: DeviceEnrollmentRequest): {
71
+ approvedDeviceID?: string;
72
+ createdAt: string;
73
+ deviceName: string;
74
+ error?: string;
75
+ expiresAt: string;
76
+ requestID: string;
77
+ signKey: string;
78
+ status: DeviceEnrollmentStatus;
79
+ username: string;
80
+ } {
81
+ return {
82
+ createdAt: new Date(req.createdAt).toISOString(),
83
+ deviceName: req.devicePayload.deviceName,
84
+ expiresAt: new Date(
85
+ req.createdAt + DEVICE_REQUEST_TTL_MS,
86
+ ).toISOString(),
87
+ requestID: req.requestID,
88
+ signKey: req.devicePayload.signKey,
89
+ status: req.status,
90
+ username: req.devicePayload.username,
91
+ ...(req.approvedDeviceID !== undefined
92
+ ? { approvedDeviceID: req.approvedDeviceID }
93
+ : {}),
94
+ ...(req.error !== undefined ? { error: req.error } : {}),
95
+ };
96
+ }
97
+
23
98
  export const getUserRouter = (
24
99
  db: Database,
25
100
  tokenValidator: (key: string, scope: TokenScopes) => boolean,
101
+ notify: (
102
+ userID: string,
103
+ event: string,
104
+ transmissionID: string,
105
+ data?: unknown,
106
+ deviceID?: string,
107
+ ) => void,
26
108
  ) => {
27
109
  const router = express.Router();
28
110
 
@@ -89,6 +171,7 @@ export const getUserRouter = (
89
171
  });
90
172
 
91
173
  router.post("/:id/devices", protect, async (req, res) => {
174
+ pruneDeviceEnrollmentRequests();
92
175
  const userDetails = getUser(req);
93
176
  const parsed = DevicePayloadSchema.safeParse(req.body);
94
177
  if (!parsed.success) {
@@ -100,7 +183,7 @@ export const getUserRouter = (
100
183
  }
101
184
  const deviceData = parsed.data;
102
185
 
103
- const token = xSignOpen(
186
+ const token = await spireXSignOpenAsync(
104
187
  XUtils.decodeHex(deviceData.signed),
105
188
  XUtils.decodeHex(deviceData.signKey),
106
189
  );
@@ -110,22 +193,242 @@ export const getUserRouter = (
110
193
  return;
111
194
  }
112
195
 
196
+ if (userDetails.userID !== getParam(req, "id")) {
197
+ res.sendStatus(401);
198
+ return;
199
+ }
200
+
201
+ const existingBySignKey = await db.retrieveDevice(deviceData.signKey);
202
+ if (existingBySignKey) {
203
+ res.sendStatus(470);
204
+ return;
205
+ }
206
+
113
207
  if (tokenValidator(stringify(token), TokenScopes.Device)) {
208
+ const userDevices = await db.retrieveUserDeviceList([
209
+ userDetails.userID,
210
+ ]);
211
+ if (userDevices.length === 0) {
212
+ try {
213
+ const device = await db.createDevice(
214
+ userDetails.userID,
215
+ deviceData,
216
+ );
217
+ res.send(msgpack.encode(device));
218
+ return;
219
+ } catch (_err: unknown) {
220
+ // signkey already taken
221
+ res.sendStatus(470);
222
+ return;
223
+ }
224
+ }
225
+
226
+ const requestID = crypto.randomUUID();
227
+ const challengeHex = XUtils.encodeHex(xRandomBytes(32));
228
+ const pending: DeviceEnrollmentRequest = {
229
+ challengeHex,
230
+ createdAt: Date.now(),
231
+ devicePayload: deviceData,
232
+ requestID,
233
+ status: "pending",
234
+ userID: userDetails.userID,
235
+ };
236
+ deviceEnrollments.set(requestID, pending);
237
+ notify(userDetails.userID, "deviceRequest", crypto.randomUUID(), {
238
+ requestID,
239
+ status: "pending",
240
+ });
241
+
242
+ res.status(202).send(
243
+ msgpack.encode({
244
+ challenge: challengeHex,
245
+ expiresAt: new Date(
246
+ pending.createdAt + DEVICE_REQUEST_TTL_MS,
247
+ ).toISOString(),
248
+ requestID,
249
+ status: "pending_approval",
250
+ }),
251
+ );
252
+ } else {
253
+ res.sendStatus(401);
254
+ }
255
+ });
256
+
257
+ router.get("/:id/devices/requests", protect, (req, res) => {
258
+ pruneDeviceEnrollmentRequests();
259
+ const userDetails = getUser(req);
260
+ const userID = getParam(req, "id");
261
+ if (userDetails.userID !== userID) {
262
+ res.sendStatus(401);
263
+ return;
264
+ }
265
+ const requests: ReturnType<typeof requestSummary>[] = [];
266
+ for (const reqItem of deviceEnrollments.values()) {
267
+ if (reqItem.userID === userID) {
268
+ requests.push(requestSummary(reqItem));
269
+ }
270
+ }
271
+ requests.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
272
+ res.send(msgpack.encode(requests));
273
+ });
274
+
275
+ router.get("/:id/devices/requests/:requestID", protect, (req, res) => {
276
+ pruneDeviceEnrollmentRequests();
277
+ const userDetails = getUser(req);
278
+ const userID = getParam(req, "id");
279
+ if (userDetails.userID !== userID) {
280
+ res.sendStatus(401);
281
+ return;
282
+ }
283
+ const requestID = getParam(req, "requestID");
284
+ const pending = deviceEnrollments.get(requestID);
285
+ if (!pending || pending.userID !== userID) {
286
+ res.sendStatus(404);
287
+ return;
288
+ }
289
+ res.send(msgpack.encode(requestSummary(pending)));
290
+ });
291
+
292
+ router.post(
293
+ "/:id/devices/requests/:requestID/approve",
294
+ protect,
295
+ async (req, res) => {
296
+ pruneDeviceEnrollmentRequests();
297
+ const userDetails = getUser(req);
298
+ const userID = getParam(req, "id");
299
+ if (userDetails.userID !== userID) {
300
+ res.sendStatus(401);
301
+ return;
302
+ }
303
+ const approverDevice = req.device;
304
+ if (!approverDevice || approverDevice.owner !== userID) {
305
+ res.status(401).send({
306
+ error: "Approve requires an authenticated existing device.",
307
+ });
308
+ return;
309
+ }
310
+
311
+ const parsedApprove = approvePayloadSchema.safeParse(req.body);
312
+ if (!parsedApprove.success) {
313
+ res.status(400).json({
314
+ error: "Invalid approval payload",
315
+ issues: parsedApprove.error.issues,
316
+ });
317
+ return;
318
+ }
319
+
320
+ const requestID = getParam(req, "requestID");
321
+ const pending = deviceEnrollments.get(requestID);
322
+ if (!pending || pending.userID !== userID) {
323
+ res.sendStatus(404);
324
+ return;
325
+ }
326
+ if (pending.status !== "pending") {
327
+ res.status(409).send({ error: "Request is not pending." });
328
+ return;
329
+ }
330
+ if (Date.now() - pending.createdAt > DEVICE_REQUEST_TTL_MS) {
331
+ pending.status = "expired";
332
+ pending.resolvedAt = Date.now();
333
+ pending.error = "Request expired.";
334
+ deviceEnrollments.set(requestID, pending);
335
+ res.status(410).send({ error: "Request expired." });
336
+ return;
337
+ }
338
+
339
+ if (approverDevice.signKey === pending.devicePayload.signKey) {
340
+ res.status(400).send({
341
+ error: "Cannot self-approve with the requesting device key.",
342
+ });
343
+ return;
344
+ }
345
+
346
+ const opened = await spireXSignOpenAsync(
347
+ XUtils.decodeHex(parsedApprove.data.signed),
348
+ XUtils.decodeHex(approverDevice.signKey),
349
+ );
350
+ if (!opened) {
351
+ res.status(401).send({ error: "Approval signature invalid." });
352
+ return;
353
+ }
354
+
355
+ const expected = XUtils.decodeUTF8(requestID);
356
+ if (!XUtils.bytesEqual(opened, expected)) {
357
+ res.status(401).send({ error: "Approval challenge mismatch." });
358
+ return;
359
+ }
360
+
114
361
  try {
115
362
  const device = await db.createDevice(
116
- userDetails.userID,
117
- deviceData,
363
+ userID,
364
+ pending.devicePayload,
118
365
  );
366
+ pending.status = "approved";
367
+ pending.approvedDeviceID = device.deviceID;
368
+ pending.resolvedAt = Date.now();
369
+ deviceEnrollments.set(requestID, pending);
370
+ notify(userID, "deviceRequest", crypto.randomUUID(), {
371
+ requestID,
372
+ status: "approved",
373
+ });
119
374
  res.send(msgpack.encode(device));
120
- } catch (_err: unknown) {
121
- // signkey already taken
375
+ return;
376
+ } catch {
377
+ pending.status = "rejected";
378
+ pending.error = "Could not create approved device.";
379
+ pending.resolvedAt = Date.now();
380
+ deviceEnrollments.set(requestID, pending);
381
+ notify(userID, "deviceRequest", crypto.randomUUID(), {
382
+ requestID,
383
+ status: "rejected",
384
+ });
122
385
  res.sendStatus(470);
123
386
  return;
124
387
  }
125
- } else {
126
- res.sendStatus(401);
127
- }
128
- });
388
+ },
389
+ );
390
+
391
+ router.post(
392
+ "/:id/devices/requests/:requestID/reject",
393
+ protect,
394
+ (req, res) => {
395
+ pruneDeviceEnrollmentRequests();
396
+ const userDetails = getUser(req);
397
+ const userID = getParam(req, "id");
398
+ if (userDetails.userID !== userID) {
399
+ res.sendStatus(401);
400
+ return;
401
+ }
402
+ const approverDevice = req.device;
403
+ if (!approverDevice || approverDevice.owner !== userID) {
404
+ res.status(401).send({
405
+ error: "Reject requires an authenticated existing device.",
406
+ });
407
+ return;
408
+ }
409
+
410
+ const requestID = getParam(req, "requestID");
411
+ const pending = deviceEnrollments.get(requestID);
412
+ if (!pending || pending.userID !== userID) {
413
+ res.sendStatus(404);
414
+ return;
415
+ }
416
+ if (pending.status !== "pending") {
417
+ res.status(409).send({ error: "Request is not pending." });
418
+ return;
419
+ }
420
+
421
+ pending.status = "rejected";
422
+ pending.resolvedAt = Date.now();
423
+ pending.error = "Rejected by existing device.";
424
+ deviceEnrollments.set(requestID, pending);
425
+ notify(userID, "deviceRequest", crypto.randomUUID(), {
426
+ requestID,
427
+ status: "rejected",
428
+ });
429
+ res.sendStatus(200);
430
+ },
431
+ );
129
432
 
130
433
  return router;
131
434
  };
@@ -0,0 +1,25 @@
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
+ /**
8
+ * Default HTTP/WS port for the Spire API (tweetnacl and FIPS), unless
9
+ * `API_PORT` / `apiPort` is set. Clients can tell profiles apart via `GET /status`.
10
+ */
11
+ export const DEFAULT_SPIRE_API_PORT = 16777;
12
+
13
+ /**
14
+ * @param explicit - `apiPort` from `SpireOptions` or `API_PORT` when set.
15
+ */
16
+ export function resolveSpireListenPort(explicit: number | undefined): number {
17
+ if (
18
+ typeof explicit === "number" &&
19
+ Number.isFinite(explicit) &&
20
+ explicit > 0
21
+ ) {
22
+ return Math.trunc(explicit);
23
+ }
24
+ return DEFAULT_SPIRE_API_PORT;
25
+ }
@@ -0,0 +1,20 @@
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 { getCryptoProfile, xSignOpen, xSignOpenAsync } from "@vex-chat/crypto";
8
+
9
+ /**
10
+ * Ed25519 detached-verify open: sync on tweetnacl, async for FIPS (Web Crypto).
11
+ */
12
+ export async function spireXSignOpenAsync(
13
+ signedMessage: Uint8Array,
14
+ publicKey: Uint8Array,
15
+ ): Promise<null | Uint8Array> {
16
+ if (getCryptoProfile() === "fips") {
17
+ return xSignOpenAsync(signedMessage, publicKey);
18
+ }
19
+ return xSignOpen(signedMessage, publicKey);
20
+ }