@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,449 @@
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
+ import express from "express";
7
+ import { XUtils } from "@vex-chat/crypto";
8
+ import { PasskeyAuthFinishPayloadSchema, PasskeyAuthStartPayloadSchema, PasskeyRegistrationFinishPayloadSchema, PasskeyRegistrationStartPayloadSchema, } from "@vex-chat/types";
9
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from "@simplewebauthn/server";
10
+ import jwt from "jsonwebtoken";
11
+ import { JWT_EXPIRY_PASSKEY } from "../Spire.js";
12
+ import { getJwtSecret } from "../utils/jwtSecret.js";
13
+ import { msgpack } from "../utils/msgpack.js";
14
+ import { AppError } from "./errors.js";
15
+ import { authLimiter } from "./rateLimit.js";
16
+ import { censorUser, getParam, getUser } from "./utils.js";
17
+ import { protect } from "./index.js";
18
+ const REGISTRATION_TTL_MS = 5 * 60 * 1000; // 5 min
19
+ const AUTHENTICATION_TTL_MS = 5 * 60 * 1000;
20
+ // Cap each user's passkey count so a compromised JWT can't fill the
21
+ // table. WebAuthn-style apps typically allow ~20; we go conservative.
22
+ const MAX_PASSKEYS_PER_USER = 10;
23
+ const pendingRegistrations = new Map();
24
+ const pendingAuthentications = new Map();
25
+ /**
26
+ * Returns the WebAuthn relying-party config from the environment.
27
+ *
28
+ * - `SPIRE_PASSKEY_RP_ID` — RP ID (eTLD+1 of the user-facing host the
29
+ * client is loaded from, e.g. `vex.wtf` or `localhost`). Required.
30
+ * - `SPIRE_PASSKEY_RP_NAME` — display name for prompts. Defaults to
31
+ * "Vex".
32
+ * - `SPIRE_PASSKEY_ORIGINS` — comma-separated allowlist of expected
33
+ * client origins (e.g. `https://app.vex.wtf,tauri://localhost,
34
+ * http://localhost:5173`). Required: WebAuthn binds an assertion
35
+ * to its origin and we must check it explicitly.
36
+ */
37
+ function getRpConfig() {
38
+ const rpID = process.env["SPIRE_PASSKEY_RP_ID"]?.trim();
39
+ const originsRaw = process.env["SPIRE_PASSKEY_ORIGINS"]?.trim();
40
+ if (!rpID) {
41
+ throw new AppError(500, "Passkeys are not configured on this server (SPIRE_PASSKEY_RP_ID is unset).");
42
+ }
43
+ if (!originsRaw) {
44
+ throw new AppError(500, "Passkeys are not configured on this server (SPIRE_PASSKEY_ORIGINS is unset).");
45
+ }
46
+ const expectedOrigin = originsRaw
47
+ .split(",")
48
+ .map((o) => o.trim())
49
+ .filter((o) => o.length > 0);
50
+ if (expectedOrigin.length === 0) {
51
+ throw new AppError(500, "SPIRE_PASSKEY_ORIGINS is empty.");
52
+ }
53
+ return {
54
+ expectedOrigin,
55
+ rpID,
56
+ rpName: process.env["SPIRE_PASSKEY_RP_NAME"]?.trim() || "Vex",
57
+ };
58
+ }
59
+ function pruneAuthentications(nowMs = Date.now()) {
60
+ for (const [id, entry] of pendingAuthentications.entries()) {
61
+ if (nowMs - entry.createdAt > AUTHENTICATION_TTL_MS) {
62
+ pendingAuthentications.delete(id);
63
+ }
64
+ }
65
+ }
66
+ function pruneRegistrations(nowMs = Date.now()) {
67
+ for (const [id, entry] of pendingRegistrations.entries()) {
68
+ if (nowMs - entry.createdAt > REGISTRATION_TTL_MS) {
69
+ pendingRegistrations.delete(id);
70
+ }
71
+ }
72
+ }
73
+ const KNOWN_TRANSPORTS = [
74
+ "ble",
75
+ "cable",
76
+ "hybrid",
77
+ "internal",
78
+ "nfc",
79
+ "smart-card",
80
+ "usb",
81
+ ];
82
+ function isKnownTransport(s) {
83
+ return KNOWN_TRANSPORTS.includes(s);
84
+ }
85
+ function sanitizeTransports(input) {
86
+ return input.filter(isKnownTransport);
87
+ }
88
+ /**
89
+ * Issues a passkey-scoped JWT.
90
+ *
91
+ * Carries `scope: "passkey"` and the owning userID; deliberately
92
+ * shorter-lived than a device JWT (5 min vs 7 days) because a
93
+ * passkey JWT grants destructive admin powers (delete a device,
94
+ * approve an enrollment) without further user verification. Callers
95
+ * re-do the WebAuthn ceremony when this expires.
96
+ */
97
+ function signPasskeyToken(args) {
98
+ return jwt.sign({
99
+ passkey: { passkeyID: args.passkeyID },
100
+ scope: "passkey",
101
+ user: args.user,
102
+ }, getJwtSecret(), { expiresIn: JWT_EXPIRY_PASSKEY });
103
+ }
104
+ export const getPasskeyRouter = (db) => {
105
+ const router = express.Router();
106
+ // ── Authenticated registration (an existing device adds a passkey) ──
107
+ router.post("/user/:id/passkeys/register/begin", protect, async (req, res) => {
108
+ const userDetails = getUser(req);
109
+ const userID = getParam(req, "id");
110
+ if (userDetails.userID !== userID) {
111
+ res.sendStatus(401);
112
+ return;
113
+ }
114
+ // Passwords/passkey JWTs both come through `protect`; only
115
+ // a real device session may add a passkey to keep the
116
+ // recovery story symmetric with device delete (a passkey
117
+ // can't bootstrap another passkey).
118
+ if (!req.device) {
119
+ res.status(401).send({
120
+ error: "Adding a passkey requires an authenticated device.",
121
+ });
122
+ return;
123
+ }
124
+ const parsed = PasskeyRegistrationStartPayloadSchema.safeParse(req.body);
125
+ if (!parsed.success) {
126
+ res.status(400).json({
127
+ error: "Invalid registration payload",
128
+ issues: parsed.error.issues,
129
+ });
130
+ return;
131
+ }
132
+ const existing = await db.retrievePasskeysByUser(userID);
133
+ if (existing.length >= MAX_PASSKEYS_PER_USER) {
134
+ res.status(409).send({
135
+ error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
136
+ });
137
+ return;
138
+ }
139
+ const { rpID, rpName } = getRpConfig();
140
+ // The userID we hand to the authenticator is the account
141
+ // userID encoded as bytes; this scopes the credential to
142
+ // the account so re-registering on the same authenticator
143
+ // updates the existing credential instead of stacking.
144
+ const userIDBytes = new TextEncoder().encode(userID);
145
+ const options = await generateRegistrationOptions({
146
+ attestationType: "none",
147
+ authenticatorSelection: {
148
+ requireResidentKey: false,
149
+ residentKey: "preferred",
150
+ userVerification: "preferred",
151
+ },
152
+ excludeCredentials: [],
153
+ rpID,
154
+ rpName,
155
+ userDisplayName: userDetails.username,
156
+ userID: userIDBytes,
157
+ userName: userDetails.username,
158
+ });
159
+ pruneRegistrations();
160
+ const requestID = crypto.randomUUID();
161
+ pendingRegistrations.set(requestID, {
162
+ challenge: options.challenge,
163
+ createdAt: Date.now(),
164
+ name: parsed.data.name,
165
+ userID,
166
+ });
167
+ // PasskeyRegistrationOptions in @vex-chat/types uses a
168
+ // looser interface than @simplewebauthn/server (so this
169
+ // shared types package doesn't take a runtime dep on
170
+ // SimpleWebAuthn). The wire shape is identical — both
171
+ // sides hand the JSON straight to navigator.credentials.
172
+ res.send(msgpack.encode({
173
+ options,
174
+ requestID,
175
+ }));
176
+ });
177
+ router.post("/user/:id/passkeys/register/finish", protect, async (req, res) => {
178
+ const userDetails = getUser(req);
179
+ const userID = getParam(req, "id");
180
+ if (userDetails.userID !== userID) {
181
+ res.sendStatus(401);
182
+ return;
183
+ }
184
+ if (!req.device) {
185
+ res.status(401).send({
186
+ error: "Adding a passkey requires an authenticated device.",
187
+ });
188
+ return;
189
+ }
190
+ const parsed = PasskeyRegistrationFinishPayloadSchema.safeParse(req.body);
191
+ if (!parsed.success) {
192
+ res.status(400).json({
193
+ error: "Invalid finish payload",
194
+ issues: parsed.error.issues,
195
+ });
196
+ return;
197
+ }
198
+ pruneRegistrations();
199
+ const pending = pendingRegistrations.get(parsed.data.requestID);
200
+ if (!pending || pending.userID !== userID) {
201
+ res.status(404).send({
202
+ error: "Registration request not found or expired.",
203
+ });
204
+ return;
205
+ }
206
+ // Single-use challenge: clear immediately so a replay can't
207
+ // re-bind the credential to a second name.
208
+ pendingRegistrations.delete(parsed.data.requestID);
209
+ const { expectedOrigin, rpID } = getRpConfig();
210
+ let verification;
211
+ try {
212
+ // The browser's RegistrationResponseJSON is opaque to
213
+ // spire — simplewebauthn does the full structural
214
+ // decode + signature verification on this argument,
215
+ // so the cast from a generic `Record<string, unknown>`
216
+ // to the branded type is the trust boundary.
217
+ const rawResponse = parsed.data.response;
218
+ verification = await verifyRegistrationResponse({
219
+ expectedChallenge: pending.challenge,
220
+ expectedOrigin,
221
+ expectedRPID: rpID,
222
+ requireUserVerification: false,
223
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
224
+ response: rawResponse,
225
+ });
226
+ }
227
+ catch (err) {
228
+ const message = err instanceof Error ? err.message : String(err);
229
+ res.status(400).send({
230
+ error: "Passkey attestation invalid: " + message,
231
+ });
232
+ return;
233
+ }
234
+ if (!verification.verified) {
235
+ res.status(400).send({ error: "Passkey attestation failed." });
236
+ return;
237
+ }
238
+ const credential = verification.registrationInfo.credential;
239
+ const dupe = await db.retrievePasskeyByCredentialID(credential.id);
240
+ if (dupe) {
241
+ res.status(409).send({
242
+ error: "This authenticator is already registered.",
243
+ });
244
+ return;
245
+ }
246
+ const transports = sanitizeTransports(credential.transports ?? []);
247
+ // Re-check the per-user cap inside the finish step in case
248
+ // a concurrent request just consumed the last available
249
+ // slot. `MAX_PASSKEYS_PER_USER` is the source of truth.
250
+ const after = await db.retrievePasskeysByUser(userID);
251
+ if (after.length >= MAX_PASSKEYS_PER_USER) {
252
+ res.status(409).send({
253
+ error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
254
+ });
255
+ return;
256
+ }
257
+ const created = await db.createPasskey(userID, pending.name, credential.id, XUtils.encodeHex(credential.publicKey),
258
+ // The COSE alg is reported back in `info.fmt`-adjacent
259
+ // helpers, but `WebAuthnCredential` only carries the
260
+ // public key; verifyAuthenticationResponse re-derives
261
+ // the alg from the COSE_Key bytes. We persist a
262
+ // best-effort algorithm hint as 0 when unavailable and
263
+ // refuse no algorithms here — verification time is
264
+ // where the real check happens.
265
+ 0, transports);
266
+ res.send(msgpack.encode(created));
267
+ });
268
+ router.get("/user/:id/passkeys", protect, async (req, res) => {
269
+ const userDetails = getUser(req);
270
+ const userID = getParam(req, "id");
271
+ if (userDetails.userID !== userID) {
272
+ res.sendStatus(401);
273
+ return;
274
+ }
275
+ const list = await db.retrievePasskeysByUser(userID);
276
+ res.send(msgpack.encode(list));
277
+ });
278
+ router.delete("/user/:id/passkeys/:passkeyID", protect, async (req, res) => {
279
+ const userDetails = getUser(req);
280
+ const userID = getParam(req, "id");
281
+ const passkeyID = getParam(req, "passkeyID");
282
+ if (userDetails.userID !== userID) {
283
+ res.sendStatus(401);
284
+ return;
285
+ }
286
+ const row = await db.retrievePasskeyInternal(passkeyID);
287
+ if (!row || row.userID !== userID) {
288
+ res.sendStatus(404);
289
+ return;
290
+ }
291
+ await db.deletePasskey(passkeyID);
292
+ res.sendStatus(200);
293
+ });
294
+ // ── Public passkey login ───────────────────────────────────────────
295
+ router.post("/auth/passkey/begin", authLimiter, async (req, res) => {
296
+ const parsed = PasskeyAuthStartPayloadSchema.safeParse(req.body);
297
+ if (!parsed.success) {
298
+ res.status(400).json({
299
+ error: "Invalid begin payload",
300
+ issues: parsed.error.issues,
301
+ });
302
+ return;
303
+ }
304
+ const user = await db.retrieveUser(parsed.data.username);
305
+ if (!user) {
306
+ // Don't reveal whether the username exists — return a
307
+ // generic 401 here. (Some flows return a generated stub
308
+ // challenge for username-less / discoverable creds; not
309
+ // implemented yet.)
310
+ res.sendStatus(401);
311
+ return;
312
+ }
313
+ const passkeys = await db.retrievePasskeysByUser(user.userID);
314
+ if (passkeys.length === 0) {
315
+ res.sendStatus(401);
316
+ return;
317
+ }
318
+ const allowCredentials = await Promise.all(passkeys.map(async (pk) => {
319
+ const internal = await db.retrievePasskeyInternal(pk.passkeyID);
320
+ return {
321
+ id: internal?.credentialID ?? "",
322
+ transports: pk.transports.filter(isKnownTransport),
323
+ type: "public-key",
324
+ };
325
+ }));
326
+ const { rpID } = getRpConfig();
327
+ const options = await generateAuthenticationOptions({
328
+ allowCredentials: allowCredentials.filter((c) => c.id.length > 0),
329
+ rpID,
330
+ userVerification: "preferred",
331
+ });
332
+ pruneAuthentications();
333
+ const requestID = crypto.randomUUID();
334
+ pendingAuthentications.set(requestID, {
335
+ challenge: options.challenge,
336
+ createdAt: Date.now(),
337
+ userID: user.userID,
338
+ });
339
+ res.send(msgpack.encode({
340
+ options,
341
+ requestID,
342
+ }));
343
+ });
344
+ router.post("/auth/passkey/finish", authLimiter, async (req, res) => {
345
+ const parsed = PasskeyAuthFinishPayloadSchema.safeParse(req.body);
346
+ if (!parsed.success) {
347
+ res.status(400).json({
348
+ error: "Invalid finish payload",
349
+ issues: parsed.error.issues,
350
+ });
351
+ return;
352
+ }
353
+ pruneAuthentications();
354
+ const pending = pendingAuthentications.get(parsed.data.requestID);
355
+ if (!pending) {
356
+ res.status(401).send({
357
+ error: "Authentication challenge not found or expired.",
358
+ });
359
+ return;
360
+ }
361
+ // Single-use.
362
+ pendingAuthentications.delete(parsed.data.requestID);
363
+ // The browser's AuthenticationResponseJSON is opaque to spire
364
+ // — simplewebauthn does the structural decode + signature
365
+ // verification. The cast from `Record<string, unknown>` to
366
+ // the branded type is the trust boundary.
367
+ const rawAssertion = parsed.data.response;
368
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
369
+ const assertion = rawAssertion;
370
+ const credentialID = typeof assertion.id === "string" ? assertion.id : "";
371
+ if (!credentialID) {
372
+ res.status(400).send({
373
+ error: "Assertion is missing a credential id.",
374
+ });
375
+ return;
376
+ }
377
+ const passkeyRow = await db.retrievePasskeyByCredentialID(credentialID);
378
+ if (!passkeyRow || passkeyRow.userID !== pending.userID) {
379
+ res.status(401).send({
380
+ error: "No matching passkey for this account.",
381
+ });
382
+ return;
383
+ }
384
+ const { expectedOrigin, rpID } = getRpConfig();
385
+ // Force `Uint8Array<ArrayBuffer>` (not `ArrayBufferLike`) so
386
+ // simplewebauthn's strict generic accepts the buffer; the
387
+ // raw decoded bytes are identical.
388
+ const credentialPublicKey = new Uint8Array(XUtils.decodeHex(passkeyRow.publicKey));
389
+ let verification;
390
+ try {
391
+ verification = await verifyAuthenticationResponse({
392
+ credential: {
393
+ counter: passkeyRow.signCount,
394
+ id: passkeyRow.credentialID,
395
+ publicKey: credentialPublicKey,
396
+ transports: passkeyRow.transports
397
+ .split(",")
398
+ .filter(isKnownTransport),
399
+ },
400
+ expectedChallenge: pending.challenge,
401
+ expectedOrigin,
402
+ expectedRPID: rpID,
403
+ requireUserVerification: false,
404
+ response: assertion,
405
+ });
406
+ }
407
+ catch (err) {
408
+ const message = err instanceof Error ? err.message : String(err);
409
+ res.status(401).send({
410
+ error: "Passkey assertion invalid: " + message,
411
+ });
412
+ return;
413
+ }
414
+ if (!verification.verified) {
415
+ res.status(401).send({ error: "Passkey assertion failed." });
416
+ return;
417
+ }
418
+ // Counter should be strictly increasing per the WebAuthn spec.
419
+ // FIDO authenticators that report 0 are excused (the spec says
420
+ // 0→0 is legitimate when the authenticator has no counter).
421
+ const newCounter = verification.authenticationInfo.newCounter;
422
+ if (newCounter !== 0 &&
423
+ passkeyRow.signCount !== 0 &&
424
+ newCounter <= passkeyRow.signCount) {
425
+ res.status(401).send({
426
+ error: "Authenticator counter regressed (possible cloned credential).",
427
+ });
428
+ return;
429
+ }
430
+ await db.markPasskeyUsed(passkeyRow.passkeyID, newCounter);
431
+ const user = await db.retrieveUser(pending.userID);
432
+ if (!user) {
433
+ res.status(404).send({ error: "Account not found." });
434
+ return;
435
+ }
436
+ const censored = censorUser(user);
437
+ const token = signPasskeyToken({
438
+ passkeyID: passkeyRow.passkeyID,
439
+ user: censored,
440
+ });
441
+ res.send(msgpack.encode({
442
+ passkeyID: passkeyRow.passkeyID,
443
+ token,
444
+ user: censored,
445
+ }));
446
+ });
447
+ return router;
448
+ };
449
+ //# sourceMappingURL=passkey.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passkey.js","sourceRoot":"","sources":["../../src/server/passkey.ts"],"names":[],"mappings":"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"}
@@ -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
+ import type { Database } from "../Database.ts";
7
+ /**
8
+ * Routes that grant a passkey-authenticated session a strictly
9
+ * bounded admin/recovery surface:
10
+ *
11
+ * - `GET /user/:id/passkey/devices` — list
12
+ * - `DELETE /user/:id/passkey/devices/:deviceID` — remove
13
+ * - `POST /user/:id/passkey/devices/requests/:requestID/approve` — approve
14
+ * - `POST /user/:id/passkey/devices/requests/:requestID/reject` — reject
15
+ *
16
+ * The route family is parallel to `/user/:id/devices/...` so the
17
+ * existing device-authenticated flow stays untouched (and there's no
18
+ * confusion about which kind of credential is doing what when a
19
+ * single endpoint accepts both).
20
+ *
21
+ * Mail/server/permissions/etc. routes are intentionally NOT mirrored
22
+ * here — passkeys are an administrative credential, not a messaging
23
+ * device.
24
+ */
25
+ export declare const getPasskeyDeviceRouter: (db: Database, notify: (userID: string, event: string, transmissionID: string, data?: unknown, deviceID?: string) => void) => import("express-serve-static-core").Router;
@@ -0,0 +1,118 @@
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
+ import express from "express";
7
+ import { msgpack } from "../utils/msgpack.js";
8
+ import { resolveDeviceEnrollmentRequest } from "./user.js";
9
+ import { getParam, getUser } from "./utils.js";
10
+ import { protectPasskey } from "./index.js";
11
+ /**
12
+ * Routes that grant a passkey-authenticated session a strictly
13
+ * bounded admin/recovery surface:
14
+ *
15
+ * - `GET /user/:id/passkey/devices` — list
16
+ * - `DELETE /user/:id/passkey/devices/:deviceID` — remove
17
+ * - `POST /user/:id/passkey/devices/requests/:requestID/approve` — approve
18
+ * - `POST /user/:id/passkey/devices/requests/:requestID/reject` — reject
19
+ *
20
+ * The route family is parallel to `/user/:id/devices/...` so the
21
+ * existing device-authenticated flow stays untouched (and there's no
22
+ * confusion about which kind of credential is doing what when a
23
+ * single endpoint accepts both).
24
+ *
25
+ * Mail/server/permissions/etc. routes are intentionally NOT mirrored
26
+ * here — passkeys are an administrative credential, not a messaging
27
+ * device.
28
+ */
29
+ export const getPasskeyDeviceRouter = (db, notify) => {
30
+ const router = express.Router();
31
+ router.get("/user/:id/passkey/devices", protectPasskey, async (req, res) => {
32
+ const userDetails = getUser(req);
33
+ const userID = getParam(req, "id");
34
+ if (userDetails.userID !== userID) {
35
+ res.sendStatus(401);
36
+ return;
37
+ }
38
+ const list = await db.retrieveUserDeviceList([userID]);
39
+ res.send(msgpack.encode(list));
40
+ });
41
+ router.delete("/user/:id/passkey/devices/:deviceID", protectPasskey, async (req, res) => {
42
+ const userDetails = getUser(req);
43
+ const userID = getParam(req, "id");
44
+ const deviceID = getParam(req, "deviceID");
45
+ if (userDetails.userID !== userID) {
46
+ res.sendStatus(401);
47
+ return;
48
+ }
49
+ const device = await db.retrieveDevice(deviceID);
50
+ if (!device || device.owner !== userID) {
51
+ res.sendStatus(404);
52
+ return;
53
+ }
54
+ // The device-auth `DELETE /user/:id/devices/:deviceID`
55
+ // refuses to delete the user's last device (a device
56
+ // can't lock itself out). Passkeys may delete the last
57
+ // device on purpose: that's the entire recovery story —
58
+ // "I lost my phone, sign in with the passkey, wipe the
59
+ // old device, then enroll a new one with the passkey
60
+ // standing in as the approver."
61
+ await db.deleteDevice(deviceID);
62
+ // Tell whoever's online that the device-list shape
63
+ // changed; clients use this to refresh the Settings →
64
+ // Devices view in real time.
65
+ notify(userID, "deviceListChanged", crypto.randomUUID());
66
+ res.sendStatus(200);
67
+ });
68
+ router.post("/user/:id/passkey/devices/requests/:requestID/approve", protectPasskey, async (req, res) => {
69
+ const userDetails = getUser(req);
70
+ const userID = getParam(req, "id");
71
+ const requestID = getParam(req, "requestID");
72
+ if (userDetails.userID !== userID) {
73
+ res.sendStatus(401);
74
+ return;
75
+ }
76
+ // No second-factor signature here: the passkey JWT itself
77
+ // is fresh proof of user presence (5 min TTL, freshly
78
+ // minted from a WebAuthn ceremony with userVerification).
79
+ // Reusing it within those 5 minutes to approve an
80
+ // enrollment is fine — the equivalent guarantee that the
81
+ // device flow gets from a per-request Ed25519 sig.
82
+ const result = await resolveDeviceEnrollmentRequest({
83
+ action: "approve",
84
+ db,
85
+ notify,
86
+ requestID,
87
+ userID,
88
+ });
89
+ if (result.kind === "ok") {
90
+ res.send(msgpack.encode(result.device));
91
+ return;
92
+ }
93
+ res.status(result.status).send({ error: result.error });
94
+ });
95
+ router.post("/user/:id/passkey/devices/requests/:requestID/reject", protectPasskey, async (req, res) => {
96
+ const userDetails = getUser(req);
97
+ const userID = getParam(req, "id");
98
+ const requestID = getParam(req, "requestID");
99
+ if (userDetails.userID !== userID) {
100
+ res.sendStatus(401);
101
+ return;
102
+ }
103
+ const result = await resolveDeviceEnrollmentRequest({
104
+ action: "reject",
105
+ db,
106
+ notify,
107
+ requestID,
108
+ userID,
109
+ });
110
+ if (result.kind === "ok") {
111
+ res.sendStatus(200);
112
+ return;
113
+ }
114
+ res.status(result.status).send({ error: result.error });
115
+ });
116
+ return router;
117
+ };
118
+ //# sourceMappingURL=passkeyDevices.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passkeyDevices.js","sourceRoot":"","sources":["../../src/server/passkeyDevices.ts"],"names":[],"mappings":"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"}
@@ -4,12 +4,38 @@
4
4
  * Commercial licenses available at vex.wtf
5
5
  */
6
6
  import type { Database } from "../Database.ts";
7
- import type { DevicePayload } from "@vex-chat/types";
7
+ import type { Device, DevicePayload } from "@vex-chat/types";
8
8
  import { TokenScopes } from "@vex-chat/types";
9
9
  export declare function createPendingDeviceEnrollmentRequest(userID: string, devicePayload: DevicePayload, notify: (userID: string, event: string, transmissionID: string, data?: unknown, deviceID?: string) => void): {
10
10
  challenge: string;
11
11
  expiresAt: string;
12
12
  requestID: string;
13
13
  status: "pending_approval";
14
+ userID: string;
14
15
  };
16
+ /**
17
+ * Reusable approve/reject side of a pending device-enrollment
18
+ * request. Lives here so both the device-authenticated router
19
+ * (signs an approval challenge with the approving device's
20
+ * Ed25519 key) and the passkey-authenticated router (relies on
21
+ * the freshness of the passkey JWT instead) can share the same
22
+ * state-machine + enrollment lifecycle.
23
+ *
24
+ * The device-auth caller is expected to have already verified the
25
+ * approving device's signature before invoking this helper.
26
+ */
27
+ export declare function resolveDeviceEnrollmentRequest(args: {
28
+ action: "approve" | "reject";
29
+ db: Database;
30
+ notify: (userID: string, event: string, transmissionID: string, data?: unknown, deviceID?: string) => void;
31
+ requestID: string;
32
+ userID: string;
33
+ }): Promise<{
34
+ device: Device;
35
+ kind: "ok";
36
+ } | {
37
+ error: string;
38
+ kind: "err";
39
+ status: number;
40
+ }>;
15
41
  export declare const getUserRouter: (db: Database, tokenValidator: (key: string, scope: TokenScopes) => boolean, notify: (userID: string, event: string, transmissionID: string, data?: unknown, deviceID?: string) => void) => import("express-serve-static-core").Router;