@vex-chat/spire 1.0.4 → 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.
- package/README.md +43 -35
- package/dist/ClientManager.js +2 -2
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +14 -16
- package/dist/Database.js +73 -101
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +13 -1
- package/dist/Spire.js +83 -20
- package/dist/Spire.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/run.js +24 -6
- package/dist/run.js.map +1 -1
- package/dist/server/index.js +4 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/user.d.ts +1 -1
- package/dist/server/user.js +238 -11
- package/dist/server/user.js.map +1 -1
- package/dist/spireListenPort.d.ts +14 -0
- package/dist/spireListenPort.js +22 -0
- package/dist/spireListenPort.js.map +1 -0
- package/dist/utils/spireXSignOpenAsync.d.ts +9 -0
- package/dist/utils/spireXSignOpenAsync.js +16 -0
- package/dist/utils/spireXSignOpenAsync.js.map +1 -0
- package/package.json +110 -121
- package/src/ClientManager.ts +2 -2
- package/src/Database.ts +84 -116
- package/src/Spire.ts +108 -26
- package/src/__tests__/Database.spec.ts +2 -1
- package/src/__tests__/spireListenPort.spec.ts +22 -0
- package/src/index.ts +4 -0
- package/src/run.ts +28 -6
- package/src/server/index.ts +8 -5
- package/src/server/user.ts +313 -10
- package/src/spireListenPort.ts +25 -0
- package/src/utils/spireXSignOpenAsync.ts +20 -0
package/src/server/user.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
}
|
|
126
|
-
|
|
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
|
+
}
|