@vex-chat/spire 1.3.7 → 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,589 @@
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
+ import type {
9
+ AuthenticationResponseJSON,
10
+ AuthenticatorTransportFuture,
11
+ RegistrationResponseJSON,
12
+ } from "@simplewebauthn/server";
13
+ import type { Passkey } from "@vex-chat/types";
14
+
15
+ import express from "express";
16
+
17
+ import { XUtils } from "@vex-chat/crypto";
18
+ import {
19
+ PasskeyAuthFinishPayloadSchema,
20
+ PasskeyAuthStartPayloadSchema,
21
+ PasskeyRegistrationFinishPayloadSchema,
22
+ PasskeyRegistrationStartPayloadSchema,
23
+ } from "@vex-chat/types";
24
+
25
+ import {
26
+ generateAuthenticationOptions,
27
+ generateRegistrationOptions,
28
+ verifyAuthenticationResponse,
29
+ verifyRegistrationResponse,
30
+ } from "@simplewebauthn/server";
31
+ import jwt from "jsonwebtoken";
32
+
33
+ import { JWT_EXPIRY_PASSKEY } from "../Spire.ts";
34
+ import { getJwtSecret } from "../utils/jwtSecret.ts";
35
+ import { msgpack } from "../utils/msgpack.ts";
36
+
37
+ import { AppError } from "./errors.ts";
38
+ import { authLimiter } from "./rateLimit.ts";
39
+ import { censorUser, getParam, getUser } from "./utils.ts";
40
+
41
+ import { protect } from "./index.ts";
42
+
43
+ const REGISTRATION_TTL_MS = 5 * 60 * 1000; // 5 min
44
+ const AUTHENTICATION_TTL_MS = 5 * 60 * 1000;
45
+ // Cap each user's passkey count so a compromised JWT can't fill the
46
+ // table. WebAuthn-style apps typically allow ~20; we go conservative.
47
+ const MAX_PASSKEYS_PER_USER = 10;
48
+
49
+ interface PendingAuthentication {
50
+ challenge: string;
51
+ createdAt: number;
52
+ userID: string;
53
+ }
54
+
55
+ interface PendingRegistration {
56
+ challenge: string;
57
+ createdAt: number;
58
+ name: string;
59
+ userID: string;
60
+ }
61
+
62
+ const pendingRegistrations = new Map<string, PendingRegistration>();
63
+ const pendingAuthentications = new Map<string, PendingAuthentication>();
64
+
65
+ /**
66
+ * Returns the WebAuthn relying-party config from the environment.
67
+ *
68
+ * - `SPIRE_PASSKEY_RP_ID` — RP ID (eTLD+1 of the user-facing host the
69
+ * client is loaded from, e.g. `vex.wtf` or `localhost`). Required.
70
+ * - `SPIRE_PASSKEY_RP_NAME` — display name for prompts. Defaults to
71
+ * "Vex".
72
+ * - `SPIRE_PASSKEY_ORIGINS` — comma-separated allowlist of expected
73
+ * client origins (e.g. `https://app.vex.wtf,tauri://localhost,
74
+ * http://localhost:5173`). Required: WebAuthn binds an assertion
75
+ * to its origin and we must check it explicitly.
76
+ */
77
+ function getRpConfig(): {
78
+ expectedOrigin: string[];
79
+ rpID: string;
80
+ rpName: string;
81
+ } {
82
+ const rpID = process.env["SPIRE_PASSKEY_RP_ID"]?.trim();
83
+ const originsRaw = process.env["SPIRE_PASSKEY_ORIGINS"]?.trim();
84
+ if (!rpID) {
85
+ throw new AppError(
86
+ 500,
87
+ "Passkeys are not configured on this server (SPIRE_PASSKEY_RP_ID is unset).",
88
+ );
89
+ }
90
+ if (!originsRaw) {
91
+ throw new AppError(
92
+ 500,
93
+ "Passkeys are not configured on this server (SPIRE_PASSKEY_ORIGINS is unset).",
94
+ );
95
+ }
96
+ const expectedOrigin = originsRaw
97
+ .split(",")
98
+ .map((o) => o.trim())
99
+ .filter((o) => o.length > 0);
100
+ if (expectedOrigin.length === 0) {
101
+ throw new AppError(500, "SPIRE_PASSKEY_ORIGINS is empty.");
102
+ }
103
+ return {
104
+ expectedOrigin,
105
+ rpID,
106
+ rpName: process.env["SPIRE_PASSKEY_RP_NAME"]?.trim() || "Vex",
107
+ };
108
+ }
109
+
110
+ function pruneAuthentications(nowMs = Date.now()): void {
111
+ for (const [id, entry] of pendingAuthentications.entries()) {
112
+ if (nowMs - entry.createdAt > AUTHENTICATION_TTL_MS) {
113
+ pendingAuthentications.delete(id);
114
+ }
115
+ }
116
+ }
117
+
118
+ function pruneRegistrations(nowMs = Date.now()): void {
119
+ for (const [id, entry] of pendingRegistrations.entries()) {
120
+ if (nowMs - entry.createdAt > REGISTRATION_TTL_MS) {
121
+ pendingRegistrations.delete(id);
122
+ }
123
+ }
124
+ }
125
+
126
+ const KNOWN_TRANSPORTS = [
127
+ "ble",
128
+ "cable",
129
+ "hybrid",
130
+ "internal",
131
+ "nfc",
132
+ "smart-card",
133
+ "usb",
134
+ ] as const satisfies readonly AuthenticatorTransportFuture[];
135
+
136
+ function isKnownTransport(s: string): s is AuthenticatorTransportFuture {
137
+ return (KNOWN_TRANSPORTS as readonly string[]).includes(s);
138
+ }
139
+
140
+ function sanitizeTransports(input: string[]): AuthenticatorTransportFuture[] {
141
+ return input.filter(isKnownTransport);
142
+ }
143
+
144
+ /**
145
+ * Issues a passkey-scoped JWT.
146
+ *
147
+ * Carries `scope: "passkey"` and the owning userID; deliberately
148
+ * shorter-lived than a device JWT (5 min vs 7 days) because a
149
+ * passkey JWT grants destructive admin powers (delete a device,
150
+ * approve an enrollment) without further user verification. Callers
151
+ * re-do the WebAuthn ceremony when this expires.
152
+ */
153
+ function signPasskeyToken(args: {
154
+ passkeyID: string;
155
+ user: ReturnType<typeof censorUser>;
156
+ }): string {
157
+ return jwt.sign(
158
+ {
159
+ passkey: { passkeyID: args.passkeyID },
160
+ scope: "passkey" as const,
161
+ user: args.user,
162
+ },
163
+ getJwtSecret(),
164
+ { expiresIn: JWT_EXPIRY_PASSKEY },
165
+ );
166
+ }
167
+
168
+ export const getPasskeyRouter = (db: Database) => {
169
+ const router = express.Router();
170
+
171
+ // ── Authenticated registration (an existing device adds a passkey) ──
172
+
173
+ router.post(
174
+ "/user/:id/passkeys/register/begin",
175
+ protect,
176
+ async (req, res) => {
177
+ const userDetails = getUser(req);
178
+ const userID = getParam(req, "id");
179
+ if (userDetails.userID !== userID) {
180
+ res.sendStatus(401);
181
+ return;
182
+ }
183
+ // Passwords/passkey JWTs both come through `protect`; only
184
+ // a real device session may add a passkey to keep the
185
+ // recovery story symmetric with device delete (a passkey
186
+ // can't bootstrap another passkey).
187
+ if (!req.device) {
188
+ res.status(401).send({
189
+ error: "Adding a passkey requires an authenticated device.",
190
+ });
191
+ return;
192
+ }
193
+
194
+ const parsed = PasskeyRegistrationStartPayloadSchema.safeParse(
195
+ req.body,
196
+ );
197
+ if (!parsed.success) {
198
+ res.status(400).json({
199
+ error: "Invalid registration payload",
200
+ issues: parsed.error.issues,
201
+ });
202
+ return;
203
+ }
204
+
205
+ const existing = await db.retrievePasskeysByUser(userID);
206
+ if (existing.length >= MAX_PASSKEYS_PER_USER) {
207
+ res.status(409).send({
208
+ error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
209
+ });
210
+ return;
211
+ }
212
+
213
+ const { rpID, rpName } = getRpConfig();
214
+ // The userID we hand to the authenticator is the account
215
+ // userID encoded as bytes; this scopes the credential to
216
+ // the account so re-registering on the same authenticator
217
+ // updates the existing credential instead of stacking.
218
+ const userIDBytes = new TextEncoder().encode(userID);
219
+
220
+ const options = await generateRegistrationOptions({
221
+ attestationType: "none",
222
+ authenticatorSelection: {
223
+ requireResidentKey: false,
224
+ residentKey: "preferred",
225
+ userVerification: "preferred",
226
+ },
227
+ excludeCredentials: [],
228
+ rpID,
229
+ rpName,
230
+ userDisplayName: userDetails.username,
231
+ userID: userIDBytes,
232
+ userName: userDetails.username,
233
+ });
234
+
235
+ pruneRegistrations();
236
+ const requestID = crypto.randomUUID();
237
+ pendingRegistrations.set(requestID, {
238
+ challenge: options.challenge,
239
+ createdAt: Date.now(),
240
+ name: parsed.data.name,
241
+ userID,
242
+ });
243
+
244
+ // PasskeyRegistrationOptions in @vex-chat/types uses a
245
+ // looser interface than @simplewebauthn/server (so this
246
+ // shared types package doesn't take a runtime dep on
247
+ // SimpleWebAuthn). The wire shape is identical — both
248
+ // sides hand the JSON straight to navigator.credentials.
249
+ res.send(
250
+ msgpack.encode({
251
+ options,
252
+ requestID,
253
+ }),
254
+ );
255
+ },
256
+ );
257
+
258
+ router.post(
259
+ "/user/:id/passkeys/register/finish",
260
+ protect,
261
+ async (req, res) => {
262
+ const userDetails = getUser(req);
263
+ const userID = getParam(req, "id");
264
+ if (userDetails.userID !== userID) {
265
+ res.sendStatus(401);
266
+ return;
267
+ }
268
+ if (!req.device) {
269
+ res.status(401).send({
270
+ error: "Adding a passkey requires an authenticated device.",
271
+ });
272
+ return;
273
+ }
274
+
275
+ const parsed = PasskeyRegistrationFinishPayloadSchema.safeParse(
276
+ req.body,
277
+ );
278
+ if (!parsed.success) {
279
+ res.status(400).json({
280
+ error: "Invalid finish payload",
281
+ issues: parsed.error.issues,
282
+ });
283
+ return;
284
+ }
285
+
286
+ pruneRegistrations();
287
+ const pending = pendingRegistrations.get(parsed.data.requestID);
288
+ if (!pending || pending.userID !== userID) {
289
+ res.status(404).send({
290
+ error: "Registration request not found or expired.",
291
+ });
292
+ return;
293
+ }
294
+ // Single-use challenge: clear immediately so a replay can't
295
+ // re-bind the credential to a second name.
296
+ pendingRegistrations.delete(parsed.data.requestID);
297
+
298
+ const { expectedOrigin, rpID } = getRpConfig();
299
+
300
+ let verification;
301
+ try {
302
+ // The browser's RegistrationResponseJSON is opaque to
303
+ // spire — simplewebauthn does the full structural
304
+ // decode + signature verification on this argument,
305
+ // so the cast from a generic `Record<string, unknown>`
306
+ // to the branded type is the trust boundary.
307
+ const rawResponse = parsed.data.response as unknown;
308
+ verification = await verifyRegistrationResponse({
309
+ expectedChallenge: pending.challenge,
310
+ expectedOrigin,
311
+ expectedRPID: rpID,
312
+ requireUserVerification: false,
313
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
314
+ response: rawResponse as RegistrationResponseJSON,
315
+ });
316
+ } catch (err: unknown) {
317
+ const message =
318
+ err instanceof Error ? err.message : String(err);
319
+ res.status(400).send({
320
+ error: "Passkey attestation invalid: " + message,
321
+ });
322
+ return;
323
+ }
324
+
325
+ if (!verification.verified) {
326
+ res.status(400).send({ error: "Passkey attestation failed." });
327
+ return;
328
+ }
329
+
330
+ const credential = verification.registrationInfo.credential;
331
+
332
+ const dupe = await db.retrievePasskeyByCredentialID(credential.id);
333
+ if (dupe) {
334
+ res.status(409).send({
335
+ error: "This authenticator is already registered.",
336
+ });
337
+ return;
338
+ }
339
+
340
+ const transports = sanitizeTransports(credential.transports ?? []);
341
+
342
+ // Re-check the per-user cap inside the finish step in case
343
+ // a concurrent request just consumed the last available
344
+ // slot. `MAX_PASSKEYS_PER_USER` is the source of truth.
345
+ const after = await db.retrievePasskeysByUser(userID);
346
+ if (after.length >= MAX_PASSKEYS_PER_USER) {
347
+ res.status(409).send({
348
+ error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
349
+ });
350
+ return;
351
+ }
352
+
353
+ const created = await db.createPasskey(
354
+ userID,
355
+ pending.name,
356
+ credential.id,
357
+ XUtils.encodeHex(credential.publicKey),
358
+ // The COSE alg is reported back in `info.fmt`-adjacent
359
+ // helpers, but `WebAuthnCredential` only carries the
360
+ // public key; verifyAuthenticationResponse re-derives
361
+ // the alg from the COSE_Key bytes. We persist a
362
+ // best-effort algorithm hint as 0 when unavailable and
363
+ // refuse no algorithms here — verification time is
364
+ // where the real check happens.
365
+ 0,
366
+ transports,
367
+ );
368
+
369
+ res.send(msgpack.encode(created));
370
+ },
371
+ );
372
+
373
+ router.get("/user/:id/passkeys", protect, async (req, res) => {
374
+ const userDetails = getUser(req);
375
+ const userID = getParam(req, "id");
376
+ if (userDetails.userID !== userID) {
377
+ res.sendStatus(401);
378
+ return;
379
+ }
380
+ const list: Passkey[] = await db.retrievePasskeysByUser(userID);
381
+ res.send(msgpack.encode(list));
382
+ });
383
+
384
+ router.delete(
385
+ "/user/:id/passkeys/:passkeyID",
386
+ protect,
387
+ async (req, res) => {
388
+ const userDetails = getUser(req);
389
+ const userID = getParam(req, "id");
390
+ const passkeyID = getParam(req, "passkeyID");
391
+ if (userDetails.userID !== userID) {
392
+ res.sendStatus(401);
393
+ return;
394
+ }
395
+ const row = await db.retrievePasskeyInternal(passkeyID);
396
+ if (!row || row.userID !== userID) {
397
+ res.sendStatus(404);
398
+ return;
399
+ }
400
+ await db.deletePasskey(passkeyID);
401
+ res.sendStatus(200);
402
+ },
403
+ );
404
+
405
+ // ── Public passkey login ───────────────────────────────────────────
406
+
407
+ router.post("/auth/passkey/begin", authLimiter, async (req, res) => {
408
+ const parsed = PasskeyAuthStartPayloadSchema.safeParse(req.body);
409
+ if (!parsed.success) {
410
+ res.status(400).json({
411
+ error: "Invalid begin payload",
412
+ issues: parsed.error.issues,
413
+ });
414
+ return;
415
+ }
416
+
417
+ const user = await db.retrieveUser(parsed.data.username);
418
+ if (!user) {
419
+ // Don't reveal whether the username exists — return a
420
+ // generic 401 here. (Some flows return a generated stub
421
+ // challenge for username-less / discoverable creds; not
422
+ // implemented yet.)
423
+ res.sendStatus(401);
424
+ return;
425
+ }
426
+
427
+ const passkeys = await db.retrievePasskeysByUser(user.userID);
428
+ if (passkeys.length === 0) {
429
+ res.sendStatus(401);
430
+ return;
431
+ }
432
+
433
+ const allowCredentials = await Promise.all(
434
+ passkeys.map(async (pk) => {
435
+ const internal = await db.retrievePasskeyInternal(pk.passkeyID);
436
+ return {
437
+ id: internal?.credentialID ?? "",
438
+ transports: pk.transports.filter(isKnownTransport),
439
+ type: "public-key" as const,
440
+ };
441
+ }),
442
+ );
443
+
444
+ const { rpID } = getRpConfig();
445
+
446
+ const options = await generateAuthenticationOptions({
447
+ allowCredentials: allowCredentials.filter((c) => c.id.length > 0),
448
+ rpID,
449
+ userVerification: "preferred",
450
+ });
451
+
452
+ pruneAuthentications();
453
+ const requestID = crypto.randomUUID();
454
+ pendingAuthentications.set(requestID, {
455
+ challenge: options.challenge,
456
+ createdAt: Date.now(),
457
+ userID: user.userID,
458
+ });
459
+
460
+ res.send(
461
+ msgpack.encode({
462
+ options,
463
+ requestID,
464
+ }),
465
+ );
466
+ });
467
+
468
+ router.post("/auth/passkey/finish", authLimiter, async (req, res) => {
469
+ const parsed = PasskeyAuthFinishPayloadSchema.safeParse(req.body);
470
+ if (!parsed.success) {
471
+ res.status(400).json({
472
+ error: "Invalid finish payload",
473
+ issues: parsed.error.issues,
474
+ });
475
+ return;
476
+ }
477
+
478
+ pruneAuthentications();
479
+ const pending = pendingAuthentications.get(parsed.data.requestID);
480
+ if (!pending) {
481
+ res.status(401).send({
482
+ error: "Authentication challenge not found or expired.",
483
+ });
484
+ return;
485
+ }
486
+ // Single-use.
487
+ pendingAuthentications.delete(parsed.data.requestID);
488
+
489
+ // The browser's AuthenticationResponseJSON is opaque to spire
490
+ // — simplewebauthn does the structural decode + signature
491
+ // verification. The cast from `Record<string, unknown>` to
492
+ // the branded type is the trust boundary.
493
+ const rawAssertion = parsed.data.response as unknown;
494
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
495
+ const assertion = rawAssertion as AuthenticationResponseJSON;
496
+ const credentialID =
497
+ typeof assertion.id === "string" ? assertion.id : "";
498
+ if (!credentialID) {
499
+ res.status(400).send({
500
+ error: "Assertion is missing a credential id.",
501
+ });
502
+ return;
503
+ }
504
+
505
+ const passkeyRow = await db.retrievePasskeyByCredentialID(credentialID);
506
+ if (!passkeyRow || passkeyRow.userID !== pending.userID) {
507
+ res.status(401).send({
508
+ error: "No matching passkey for this account.",
509
+ });
510
+ return;
511
+ }
512
+
513
+ const { expectedOrigin, rpID } = getRpConfig();
514
+
515
+ // Force `Uint8Array<ArrayBuffer>` (not `ArrayBufferLike`) so
516
+ // simplewebauthn's strict generic accepts the buffer; the
517
+ // raw decoded bytes are identical.
518
+ const credentialPublicKey = new Uint8Array(
519
+ XUtils.decodeHex(passkeyRow.publicKey),
520
+ );
521
+
522
+ let verification;
523
+ try {
524
+ verification = await verifyAuthenticationResponse({
525
+ credential: {
526
+ counter: passkeyRow.signCount,
527
+ id: passkeyRow.credentialID,
528
+ publicKey: credentialPublicKey,
529
+ transports: passkeyRow.transports
530
+ .split(",")
531
+ .filter(isKnownTransport),
532
+ },
533
+ expectedChallenge: pending.challenge,
534
+ expectedOrigin,
535
+ expectedRPID: rpID,
536
+ requireUserVerification: false,
537
+ response: assertion,
538
+ });
539
+ } catch (err: unknown) {
540
+ const message = err instanceof Error ? err.message : String(err);
541
+ res.status(401).send({
542
+ error: "Passkey assertion invalid: " + message,
543
+ });
544
+ return;
545
+ }
546
+
547
+ if (!verification.verified) {
548
+ res.status(401).send({ error: "Passkey assertion failed." });
549
+ return;
550
+ }
551
+
552
+ // Counter should be strictly increasing per the WebAuthn spec.
553
+ // FIDO authenticators that report 0 are excused (the spec says
554
+ // 0→0 is legitimate when the authenticator has no counter).
555
+ const newCounter = verification.authenticationInfo.newCounter;
556
+ if (
557
+ newCounter !== 0 &&
558
+ passkeyRow.signCount !== 0 &&
559
+ newCounter <= passkeyRow.signCount
560
+ ) {
561
+ res.status(401).send({
562
+ error: "Authenticator counter regressed (possible cloned credential).",
563
+ });
564
+ return;
565
+ }
566
+
567
+ await db.markPasskeyUsed(passkeyRow.passkeyID, newCounter);
568
+
569
+ const user = await db.retrieveUser(pending.userID);
570
+ if (!user) {
571
+ res.status(404).send({ error: "Account not found." });
572
+ return;
573
+ }
574
+ const censored = censorUser(user);
575
+ const token = signPasskeyToken({
576
+ passkeyID: passkeyRow.passkeyID,
577
+ user: censored,
578
+ });
579
+ res.send(
580
+ msgpack.encode({
581
+ passkeyID: passkeyRow.passkeyID,
582
+ token,
583
+ user: censored,
584
+ }),
585
+ );
586
+ });
587
+
588
+ return router;
589
+ };