@vex-chat/spire 3.0.1 → 4.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.
Files changed (90) hide show
  1. package/README.md +9 -8
  2. package/dist/Database.d.ts +20 -11
  3. package/dist/Database.js +229 -118
  4. package/dist/Database.js.map +1 -1
  5. package/dist/Spire.d.ts +4 -2
  6. package/dist/Spire.js +155 -72
  7. package/dist/Spire.js.map +1 -1
  8. package/dist/db/schema.d.ts +0 -2
  9. package/dist/migrations/2026-04-06_initial-schema.js +4 -5
  10. package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
  11. package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
  12. package/dist/migrations/2026-07-14_query-indexes.js +31 -0
  13. package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
  14. package/dist/server/avatar.js +33 -6
  15. package/dist/server/avatar.js.map +1 -1
  16. package/dist/server/cliPasskeyPage.js +109 -11
  17. package/dist/server/cliPasskeyPage.js.map +1 -1
  18. package/dist/server/errors.js +8 -0
  19. package/dist/server/errors.js.map +1 -1
  20. package/dist/server/file.js +35 -12
  21. package/dist/server/file.js.map +1 -1
  22. package/dist/server/index.d.ts +8 -0
  23. package/dist/server/index.js +319 -56
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/passkey.js +367 -29
  26. package/dist/server/passkey.js.map +1 -1
  27. package/dist/server/passkeyDevices.js +1 -0
  28. package/dist/server/passkeyDevices.js.map +1 -1
  29. package/dist/server/password.d.ts +7 -0
  30. package/dist/server/password.js +58 -0
  31. package/dist/server/password.js.map +1 -0
  32. package/dist/server/permissions.d.ts +1 -0
  33. package/dist/server/permissions.js +11 -2
  34. package/dist/server/permissions.js.map +1 -1
  35. package/dist/server/rateLimit.d.ts +11 -0
  36. package/dist/server/rateLimit.js +43 -4
  37. package/dist/server/rateLimit.js.map +1 -1
  38. package/dist/server/serverIcon.d.ts +10 -0
  39. package/dist/server/serverIcon.js +158 -0
  40. package/dist/server/serverIcon.js.map +1 -0
  41. package/dist/server/user.d.ts +1 -1
  42. package/dist/server/user.js +40 -9
  43. package/dist/server/user.js.map +1 -1
  44. package/dist/types/express.d.ts +3 -4
  45. package/dist/types/express.js.map +1 -1
  46. package/dist/utils/authJwt.d.ts +8 -0
  47. package/dist/utils/authJwt.js +25 -0
  48. package/dist/utils/authJwt.js.map +1 -0
  49. package/dist/utils/loadEnv.js +4 -0
  50. package/dist/utils/loadEnv.js.map +1 -1
  51. package/dist/utils/preKeySignature.d.ts +1 -1
  52. package/dist/utils/preKeySignature.js +7 -11
  53. package/dist/utils/preKeySignature.js.map +1 -1
  54. package/package.json +7 -7
  55. package/src/Database.ts +294 -152
  56. package/src/Spire.ts +412 -285
  57. package/src/__tests__/Database.spec.ts +126 -3
  58. package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
  59. package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
  60. package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
  61. package/src/__tests__/connectAuth.spec.ts +146 -4
  62. package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
  63. package/src/__tests__/passkeyDevices.spec.ts +94 -0
  64. package/src/__tests__/passkeys.spec.ts +7 -2
  65. package/src/__tests__/password.spec.ts +223 -0
  66. package/src/__tests__/permissions.spec.ts +50 -0
  67. package/src/__tests__/preKeySignature.spec.ts +20 -3
  68. package/src/__tests__/serverManagement.spec.ts +262 -0
  69. package/src/db/schema.ts +0 -2
  70. package/src/migrations/2026-04-06_initial-schema.ts +4 -5
  71. package/src/migrations/2026-07-14_query-indexes.ts +34 -0
  72. package/src/server/avatar.ts +32 -6
  73. package/src/server/cliPasskeyPage.ts +109 -11
  74. package/src/server/errors.ts +7 -0
  75. package/src/server/file.ts +34 -13
  76. package/src/server/index.ts +494 -139
  77. package/src/server/passkey.ts +531 -31
  78. package/src/server/passkeyDevices.ts +1 -0
  79. package/src/server/password.ts +96 -0
  80. package/src/server/permissions.ts +20 -2
  81. package/src/server/rateLimit.ts +47 -4
  82. package/src/server/serverIcon.ts +199 -0
  83. package/src/server/user.ts +44 -9
  84. package/src/types/express.ts +3 -4
  85. package/src/utils/authJwt.ts +34 -0
  86. package/src/utils/loadEnv.ts +4 -0
  87. package/src/utils/preKeySignature.ts +12 -15
  88. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
  89. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
  90. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
@@ -12,6 +12,8 @@ import type {
12
12
  } from "@simplewebauthn/server";
13
13
  import type { Passkey } from "@vex-chat/types";
14
14
 
15
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
16
+
15
17
  import express from "express";
16
18
 
17
19
  import { XUtils } from "@vex-chat/crypto";
@@ -28,10 +30,10 @@ import {
28
30
  verifyAuthenticationResponse,
29
31
  verifyRegistrationResponse,
30
32
  } from "@simplewebauthn/server";
31
- import jwt from "jsonwebtoken";
33
+ import { z } from "zod";
32
34
 
33
35
  import { JWT_EXPIRY_PASSKEY } from "../Spire.ts";
34
- import { getJwtSecret } from "../utils/jwtSecret.ts";
36
+ import { signAuthJwt } from "../utils/authJwt.ts";
35
37
 
36
38
  import { AppError } from "./errors.ts";
37
39
  import { authLimiter } from "./rateLimit.ts";
@@ -39,29 +41,159 @@ import { censorUser, getParam, getUser } from "./utils.ts";
39
41
  import { buildAndroidApkKeyHashOrigins } from "./wellKnown.ts";
40
42
  import { sendWireResponse } from "./wireResponse.ts";
41
43
 
42
- import { protect } from "./index.ts";
44
+ import { protect, protectAnyAuth } from "./index.ts";
43
45
 
44
46
  const REGISTRATION_TTL_MS = 5 * 60 * 1000; // 5 min
45
47
  const AUTHENTICATION_TTL_MS = 5 * 60 * 1000;
48
+ const BROWSER_AUTHENTICATION_TTL_MS = 5 * 60 * 1000;
49
+ const BROWSER_REGISTRATION_TTL_MS = 5 * 60 * 1000;
46
50
  // Cap each user's passkey count so a compromised JWT can't fill the
47
51
  // table. WebAuthn-style apps typically allow ~20; we go conservative.
48
52
  const MAX_PASSKEYS_PER_USER = 10;
49
53
 
54
+ type AuthenticationOptions = Awaited<
55
+ ReturnType<typeof generateAuthenticationOptions>
56
+ >;
57
+
58
+ type BrowserAuthenticationStatus = "in_progress" | "pending" | "response_ready";
59
+ type BrowserRegistrationStatus = "failed" | "in_progress" | "pending";
60
+
50
61
  interface PendingAuthentication {
62
+ browserRequestID: string;
51
63
  challenge: string;
52
64
  createdAt: number;
65
+ options: AuthenticationOptions;
53
66
  userID: string;
54
67
  }
55
68
 
69
+ interface PendingBrowserAuthentication {
70
+ authenticationRequestID: string;
71
+ createdAt: number;
72
+ response?: Record<string, unknown>;
73
+ status: BrowserAuthenticationStatus;
74
+ tokenDigest: Buffer;
75
+ }
76
+
77
+ interface PendingBrowserRegistration {
78
+ challenge?: string;
79
+ createdAt: number;
80
+ deviceID: string;
81
+ error?: string;
82
+ name: string;
83
+ registrationRequestID: string;
84
+ status: BrowserRegistrationStatus;
85
+ tokenDigest: Buffer;
86
+ userID: string;
87
+ username: string;
88
+ }
89
+
56
90
  interface PendingRegistration {
91
+ browserRequestID: string;
57
92
  challenge: string;
58
93
  createdAt: number;
94
+ deviceID: string;
59
95
  name: string;
60
96
  userID: string;
61
97
  }
62
98
 
63
99
  const pendingRegistrations = new Map<string, PendingRegistration>();
64
100
  const pendingAuthentications = new Map<string, PendingAuthentication>();
101
+ const pendingBrowserAuthentications = new Map<
102
+ string,
103
+ PendingBrowserAuthentication
104
+ >();
105
+ const pendingBrowserRegistrations = new Map<
106
+ string,
107
+ PendingBrowserRegistration
108
+ >();
109
+
110
+ const BrowserHandoffTokenSchema = z.object({
111
+ token: z.string().min(32).max(256),
112
+ });
113
+ const BrowserAuthenticationFinishSchema = BrowserHandoffTokenSchema.extend({
114
+ response: z.record(z.string(), z.unknown()),
115
+ });
116
+ const BrowserRegistrationFinishSchema = BrowserHandoffTokenSchema.extend({
117
+ response: z.record(z.string(), z.unknown()),
118
+ });
119
+
120
+ function browserTokenDigest(token: string): Buffer {
121
+ return createHash("sha256").update(token, "utf8").digest();
122
+ }
123
+
124
+ function browserTokenMatches(
125
+ pending: { tokenDigest: Buffer },
126
+ token: string,
127
+ ): boolean {
128
+ return timingSafeEqual(pending.tokenDigest, browserTokenDigest(token));
129
+ }
130
+
131
+ function createBrowserAuthentication(authenticationRequestID: string): {
132
+ browserToken: string;
133
+ expiresAt: string;
134
+ requestID: string;
135
+ } {
136
+ pruneBrowserAuthentications();
137
+ const requestID = crypto.randomUUID();
138
+ const browserToken = randomBytes(32).toString("base64url");
139
+ const createdAt = Date.now();
140
+ pendingBrowserAuthentications.set(requestID, {
141
+ authenticationRequestID,
142
+ createdAt,
143
+ status: "pending",
144
+ tokenDigest: browserTokenDigest(browserToken),
145
+ });
146
+ return {
147
+ browserToken,
148
+ expiresAt: new Date(
149
+ createdAt + BROWSER_AUTHENTICATION_TTL_MS,
150
+ ).toISOString(),
151
+ requestID,
152
+ };
153
+ }
154
+
155
+ function createBrowserRegistration(args: {
156
+ deviceID: string;
157
+ name: string;
158
+ registrationRequestID: string;
159
+ userID: string;
160
+ username: string;
161
+ }): {
162
+ browserToken: string;
163
+ expiresAt: string;
164
+ requestID: string;
165
+ } {
166
+ pruneBrowserRegistrations();
167
+ const requestID = crypto.randomUUID();
168
+ const browserToken = randomBytes(32).toString("base64url");
169
+ const createdAt = Date.now();
170
+ pendingBrowserRegistrations.set(requestID, {
171
+ createdAt,
172
+ deviceID: args.deviceID,
173
+ name: args.name,
174
+ registrationRequestID: args.registrationRequestID,
175
+ status: "pending",
176
+ tokenDigest: browserTokenDigest(browserToken),
177
+ userID: args.userID,
178
+ username: args.username,
179
+ });
180
+ return {
181
+ browserToken,
182
+ expiresAt: new Date(
183
+ createdAt + BROWSER_REGISTRATION_TTL_MS,
184
+ ).toISOString(),
185
+ requestID,
186
+ };
187
+ }
188
+
189
+ function failBrowserRegistration(
190
+ pending: PendingBrowserRegistration,
191
+ error: string,
192
+ ): void {
193
+ delete pending.challenge;
194
+ pending.error = error;
195
+ pending.status = "failed";
196
+ }
65
197
 
66
198
  /**
67
199
  * Returns the WebAuthn relying-party config from the environment.
@@ -71,7 +203,7 @@ const pendingAuthentications = new Map<string, PendingAuthentication>();
71
203
  * - `SPIRE_PASSKEY_RP_NAME` — display name for prompts. Defaults to
72
204
  * "Vex".
73
205
  * - `SPIRE_PASSKEY_ORIGINS` — comma-separated allowlist of expected
74
- * client origins (e.g. `https://app.vex.wtf,tauri://localhost,
206
+ * client origins (e.g. `https://app.vex.wtf,
75
207
  * http://localhost:5173`). Required: WebAuthn binds an assertion
76
208
  * to its origin and we must check it explicitly.
77
209
  *
@@ -123,6 +255,23 @@ function pruneAuthentications(nowMs = Date.now()): void {
123
255
  for (const [id, entry] of pendingAuthentications.entries()) {
124
256
  if (nowMs - entry.createdAt > AUTHENTICATION_TTL_MS) {
125
257
  pendingAuthentications.delete(id);
258
+ pendingBrowserAuthentications.delete(entry.browserRequestID);
259
+ }
260
+ }
261
+ }
262
+
263
+ function pruneBrowserAuthentications(nowMs = Date.now()): void {
264
+ for (const [id, entry] of pendingBrowserAuthentications.entries()) {
265
+ if (nowMs - entry.createdAt > BROWSER_AUTHENTICATION_TTL_MS) {
266
+ pendingBrowserAuthentications.delete(id);
267
+ }
268
+ }
269
+ }
270
+
271
+ function pruneBrowserRegistrations(nowMs = Date.now()): void {
272
+ for (const [id, entry] of pendingBrowserRegistrations.entries()) {
273
+ if (nowMs - entry.createdAt > BROWSER_REGISTRATION_TTL_MS) {
274
+ pendingBrowserRegistrations.delete(id);
126
275
  }
127
276
  }
128
277
  }
@@ -157,7 +306,7 @@ function sanitizeTransports(input: string[]): AuthenticatorTransportFuture[] {
157
306
  * Issues a passkey-scoped JWT.
158
307
  *
159
308
  * Carries `scope: "passkey"` and the owning userID; deliberately
160
- * shorter-lived than a device JWT (5 min vs 7 days) because a
309
+ * shorter-lived than an account or device JWT (5 min vs 1 hour) because a
161
310
  * passkey JWT grants destructive admin powers (delete a device,
162
311
  * approve an enrollment) without further user verification. Callers
163
312
  * re-do the WebAuthn ceremony when this expires.
@@ -166,14 +315,13 @@ function signPasskeyToken(args: {
166
315
  passkeyID: string;
167
316
  user: ReturnType<typeof censorUser>;
168
317
  }): string {
169
- return jwt.sign(
318
+ return signAuthJwt(
170
319
  {
171
320
  passkey: { passkeyID: args.passkeyID },
172
321
  scope: "passkey" as const,
173
322
  user: args.user,
174
323
  },
175
- getJwtSecret(),
176
- { expiresIn: JWT_EXPIRY_PASSKEY },
324
+ JWT_EXPIRY_PASSKEY,
177
325
  );
178
326
  }
179
327
 
@@ -203,16 +351,13 @@ export const getPasskeyRouter = (db: Database) => {
203
351
  return;
204
352
  }
205
353
 
206
- const existing = await db.retrievePasskeysByUser(userID);
207
- // A freshly registered account may add its first passkey with
208
- // the account bearer before finishing device connect. Every later
209
- // passkey addition must come from an authenticated device session.
210
- if (!req.device && existing.length > 0) {
354
+ if (!req.device || req.device.owner !== userID) {
211
355
  res.status(401).send({
212
- error: "Adding another passkey requires an authenticated device.",
356
+ error: "Adding a passkey requires an authenticated device.",
213
357
  });
214
358
  return;
215
359
  }
360
+ const existing = await db.retrievePasskeysByUser(userID);
216
361
  if (existing.length >= MAX_PASSKEYS_PER_USER) {
217
362
  res.status(409).send({
218
363
  error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
@@ -232,7 +377,7 @@ export const getPasskeyRouter = (db: Database) => {
232
377
  authenticatorSelection: {
233
378
  requireResidentKey: false,
234
379
  residentKey: "preferred",
235
- userVerification: "preferred",
380
+ userVerification: "required",
236
381
  },
237
382
  excludeCredentials: [],
238
383
  rpID,
@@ -244,9 +389,18 @@ export const getPasskeyRouter = (db: Database) => {
244
389
 
245
390
  pruneRegistrations();
246
391
  const requestID = crypto.randomUUID();
392
+ const browserHandoff = createBrowserRegistration({
393
+ deviceID: req.device.deviceID,
394
+ name: parsed.data.name,
395
+ registrationRequestID: requestID,
396
+ userID,
397
+ username: userDetails.username,
398
+ });
247
399
  pendingRegistrations.set(requestID, {
400
+ browserRequestID: browserHandoff.requestID,
248
401
  challenge: options.challenge,
249
402
  createdAt: Date.now(),
403
+ deviceID: req.device.deviceID,
250
404
  name: parsed.data.name,
251
405
  userID,
252
406
  });
@@ -257,7 +411,11 @@ export const getPasskeyRouter = (db: Database) => {
257
411
  // SimpleWebAuthn). The wire shape is identical — both
258
412
  // sides hand the JSON straight to navigator.credentials.
259
413
  sendWireResponse(req, res, {
260
- options,
414
+ // Older libvex releases preserve the opaque options object but
415
+ // strip unknown top-level fields. Keep the handoff inside that
416
+ // object so desktop can adopt the HTTPS bridge before its next
417
+ // package bump; WebAuthn ignores unknown dictionary members.
418
+ options: { ...options, vexBrowserHandoff: browserHandoff },
261
419
  requestID,
262
420
  });
263
421
  },
@@ -285,17 +443,20 @@ export const getPasskeyRouter = (db: Database) => {
285
443
  return;
286
444
  }
287
445
 
288
- const existing = await db.retrievePasskeysByUser(userID);
289
- if (!req.device && existing.length > 0) {
446
+ if (!req.device || req.device.owner !== userID) {
290
447
  res.status(401).send({
291
- error: "Adding another passkey requires an authenticated device.",
448
+ error: "Adding a passkey requires an authenticated device.",
292
449
  });
293
450
  return;
294
451
  }
295
452
 
296
453
  pruneRegistrations();
297
454
  const pending = pendingRegistrations.get(parsed.data.requestID);
298
- if (!pending || pending.userID !== userID) {
455
+ if (
456
+ !pending ||
457
+ pending.userID !== userID ||
458
+ pending.deviceID !== req.device.deviceID
459
+ ) {
299
460
  res.status(404).send({
300
461
  error: "Registration request not found or expired.",
301
462
  });
@@ -304,6 +465,7 @@ export const getPasskeyRouter = (db: Database) => {
304
465
  // Single-use challenge: clear immediately so a replay can't
305
466
  // re-bind the credential to a second name.
306
467
  pendingRegistrations.delete(parsed.data.requestID);
468
+ pendingBrowserRegistrations.delete(pending.browserRequestID);
307
469
 
308
470
  const { expectedOrigin, rpID } = getRpConfig();
309
471
 
@@ -319,15 +481,14 @@ export const getPasskeyRouter = (db: Database) => {
319
481
  expectedChallenge: pending.challenge,
320
482
  expectedOrigin,
321
483
  expectedRPID: rpID,
322
- requireUserVerification: false,
484
+ requireUserVerification: true,
323
485
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
324
486
  response: rawResponse as RegistrationResponseJSON,
325
487
  });
326
488
  } catch (err: unknown) {
327
- const message =
328
- err instanceof Error ? err.message : String(err);
489
+ logWebAuthnFailure("registration", err);
329
490
  res.status(400).send({
330
- error: "Passkey attestation invalid: " + message,
491
+ error: "Passkey attestation could not be verified.",
331
492
  });
332
493
  return;
333
494
  }
@@ -380,7 +541,7 @@ export const getPasskeyRouter = (db: Database) => {
380
541
  },
381
542
  );
382
543
 
383
- router.get("/user/:id/passkeys", protect, async (req, res) => {
544
+ router.get("/user/:id/passkeys", protectAnyAuth, async (req, res) => {
384
545
  const userDetails = getUser(req);
385
546
  const userID = getParam(req, "id");
386
547
  if (userDetails.userID !== userID) {
@@ -402,6 +563,12 @@ export const getPasskeyRouter = (db: Database) => {
402
563
  res.sendStatus(401);
403
564
  return;
404
565
  }
566
+ if (!req.device || req.device.owner !== userID) {
567
+ res.status(401).send({
568
+ error: "Removing a passkey requires an authenticated device.",
569
+ });
570
+ return;
571
+ }
405
572
  const row = await db.retrievePasskeyInternal(passkeyID);
406
573
  if (!row || row.userID !== userID) {
407
574
  res.sendStatus(404);
@@ -414,6 +581,317 @@ export const getPasskeyRouter = (db: Database) => {
414
581
 
415
582
  // ── Public passkey login ───────────────────────────────────────────
416
583
 
584
+ router.post(
585
+ "/auth/passkey/browser-registration/:requestID/begin",
586
+ authLimiter,
587
+ async (req, res) => {
588
+ const parsed = BrowserHandoffTokenSchema.safeParse(req.body);
589
+ if (!parsed.success) {
590
+ res.status(400).send({ error: "Invalid browser handoff." });
591
+ return;
592
+ }
593
+ pruneBrowserRegistrations();
594
+ const pending = pendingBrowserRegistrations.get(
595
+ getParam(req, "requestID"),
596
+ );
597
+ if (!pending || !browserTokenMatches(pending, parsed.data.token)) {
598
+ res.status(401).send({
599
+ error: "Browser handoff is invalid or expired.",
600
+ });
601
+ return;
602
+ }
603
+ if (pending.status === "failed") {
604
+ res.status(409).send({
605
+ error:
606
+ pending.error ??
607
+ "Browser handoff has already completed.",
608
+ });
609
+ return;
610
+ }
611
+ const device = await db.retrieveDevice(pending.deviceID);
612
+ if (!device || device.owner !== pending.userID) {
613
+ failBrowserRegistration(
614
+ pending,
615
+ "The originating device is no longer approved.",
616
+ );
617
+ res.status(401).send({ error: pending.error });
618
+ return;
619
+ }
620
+ const existing = await db.retrievePasskeysByUser(pending.userID);
621
+ if (existing.length >= MAX_PASSKEYS_PER_USER) {
622
+ failBrowserRegistration(
623
+ pending,
624
+ `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
625
+ );
626
+ res.status(409).send({ error: pending.error });
627
+ return;
628
+ }
629
+ // The browser and native challenges represent one user action.
630
+ // Once the HTTPS path starts, the custom-origin path cannot also
631
+ // finish and create a second credential.
632
+ pendingRegistrations.delete(pending.registrationRequestID);
633
+
634
+ const { rpID, rpName } = getRpConfig();
635
+ const options = await generateRegistrationOptions({
636
+ attestationType: "none",
637
+ authenticatorSelection: {
638
+ requireResidentKey: false,
639
+ residentKey: "preferred",
640
+ userVerification: "required",
641
+ },
642
+ excludeCredentials: [],
643
+ rpID,
644
+ rpName,
645
+ userDisplayName: pending.username,
646
+ userID: new TextEncoder().encode(pending.userID),
647
+ userName: pending.username,
648
+ });
649
+ pending.challenge = options.challenge;
650
+ delete pending.error;
651
+ pending.status = "in_progress";
652
+ sendWireResponse(req, res, { options });
653
+ },
654
+ );
655
+
656
+ router.post(
657
+ "/auth/passkey/browser-registration/:requestID/finish",
658
+ authLimiter,
659
+ async (req, res) => {
660
+ const parsed = BrowserRegistrationFinishSchema.safeParse(req.body);
661
+ if (!parsed.success) {
662
+ res.status(400).send({
663
+ error: "Invalid browser handoff response.",
664
+ });
665
+ return;
666
+ }
667
+ pruneBrowserRegistrations();
668
+ const pending = pendingBrowserRegistrations.get(
669
+ getParam(req, "requestID"),
670
+ );
671
+ if (!pending || !browserTokenMatches(pending, parsed.data.token)) {
672
+ res.status(401).send({
673
+ error: "Browser handoff is invalid or expired.",
674
+ });
675
+ return;
676
+ }
677
+ if (pending.status !== "in_progress" || !pending.challenge) {
678
+ res.status(409).send({
679
+ error: "Request a fresh passkey challenge and try again.",
680
+ });
681
+ return;
682
+ }
683
+ const challenge = pending.challenge;
684
+ delete pending.challenge;
685
+
686
+ const device = await db.retrieveDevice(pending.deviceID);
687
+ if (!device || device.owner !== pending.userID) {
688
+ failBrowserRegistration(
689
+ pending,
690
+ "The originating device is no longer approved.",
691
+ );
692
+ res.status(401).send({ error: pending.error });
693
+ return;
694
+ }
695
+
696
+ const { expectedOrigin, rpID } = getRpConfig();
697
+ let verification;
698
+ try {
699
+ verification = await verifyRegistrationResponse({
700
+ expectedChallenge: challenge,
701
+ expectedOrigin,
702
+ expectedRPID: rpID,
703
+ requireUserVerification: true,
704
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- structurally validated by simplewebauthn below
705
+ response: parsed.data
706
+ .response as unknown as RegistrationResponseJSON,
707
+ });
708
+ } catch (err: unknown) {
709
+ logWebAuthnFailure("browser registration", err);
710
+ failBrowserRegistration(
711
+ pending,
712
+ "Passkey attestation could not be verified.",
713
+ );
714
+ res.status(400).send({ error: pending.error });
715
+ return;
716
+ }
717
+ if (!verification.verified) {
718
+ failBrowserRegistration(pending, "Passkey attestation failed.");
719
+ res.status(400).send({ error: pending.error });
720
+ return;
721
+ }
722
+
723
+ const credential = verification.registrationInfo.credential;
724
+ const duplicate = await db.retrievePasskeyByCredentialID(
725
+ credential.id,
726
+ );
727
+ if (duplicate) {
728
+ failBrowserRegistration(
729
+ pending,
730
+ "This authenticator is already registered.",
731
+ );
732
+ res.status(409).send({ error: pending.error });
733
+ return;
734
+ }
735
+ const existing = await db.retrievePasskeysByUser(pending.userID);
736
+ if (existing.length >= MAX_PASSKEYS_PER_USER) {
737
+ failBrowserRegistration(
738
+ pending,
739
+ `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
740
+ );
741
+ res.status(409).send({ error: pending.error });
742
+ return;
743
+ }
744
+
745
+ const created = await db.createPasskey(
746
+ pending.userID,
747
+ pending.name,
748
+ credential.id,
749
+ XUtils.encodeHex(credential.publicKey),
750
+ 0,
751
+ sanitizeTransports(credential.transports ?? []),
752
+ );
753
+ pendingBrowserRegistrations.delete(getParam(req, "requestID"));
754
+ sendWireResponse(req, res, created);
755
+ },
756
+ );
757
+
758
+ router.post(
759
+ "/auth/passkey/browser-authentication/:requestID/begin",
760
+ authLimiter,
761
+ (req, res) => {
762
+ const parsed = BrowserHandoffTokenSchema.safeParse(req.body);
763
+ if (!parsed.success) {
764
+ res.status(400).send({ error: "Invalid browser handoff." });
765
+ return;
766
+ }
767
+ pruneAuthentications();
768
+ pruneBrowserAuthentications();
769
+ const browserRequestID = getParam(req, "requestID");
770
+ const browserPending =
771
+ pendingBrowserAuthentications.get(browserRequestID);
772
+ if (
773
+ !browserPending ||
774
+ !browserTokenMatches(browserPending, parsed.data.token)
775
+ ) {
776
+ res.status(401).send({
777
+ error: "Browser handoff is invalid or expired.",
778
+ });
779
+ return;
780
+ }
781
+ const authenticationPending = pendingAuthentications.get(
782
+ browserPending.authenticationRequestID,
783
+ );
784
+ if (!authenticationPending) {
785
+ pendingBrowserAuthentications.delete(browserRequestID);
786
+ res.status(401).send({
787
+ error: "Authentication request is invalid or expired.",
788
+ });
789
+ return;
790
+ }
791
+ if (browserPending.status === "response_ready") {
792
+ res.status(409).send({
793
+ error: "This browser handoff has already completed.",
794
+ });
795
+ return;
796
+ }
797
+ browserPending.status = "in_progress";
798
+ sendWireResponse(req, res, {
799
+ options: authenticationPending.options,
800
+ });
801
+ },
802
+ );
803
+
804
+ router.post(
805
+ "/auth/passkey/browser-authentication/:requestID/finish",
806
+ authLimiter,
807
+ (req, res) => {
808
+ const parsed = BrowserAuthenticationFinishSchema.safeParse(
809
+ req.body,
810
+ );
811
+ if (!parsed.success) {
812
+ res.status(400).send({
813
+ error: "Invalid browser handoff response.",
814
+ });
815
+ return;
816
+ }
817
+ pruneAuthentications();
818
+ pruneBrowserAuthentications();
819
+ const browserRequestID = getParam(req, "requestID");
820
+ const browserPending =
821
+ pendingBrowserAuthentications.get(browserRequestID);
822
+ if (
823
+ !browserPending ||
824
+ !browserTokenMatches(browserPending, parsed.data.token)
825
+ ) {
826
+ res.status(401).send({
827
+ error: "Browser handoff is invalid or expired.",
828
+ });
829
+ return;
830
+ }
831
+ if (
832
+ browserPending.status !== "in_progress" ||
833
+ !pendingAuthentications.has(
834
+ browserPending.authenticationRequestID,
835
+ )
836
+ ) {
837
+ res.status(409).send({
838
+ error: "Request a fresh passkey challenge and try again.",
839
+ });
840
+ return;
841
+ }
842
+ browserPending.response = parsed.data.response;
843
+ browserPending.status = "response_ready";
844
+ sendWireResponse(req, res, { ok: true });
845
+ },
846
+ );
847
+
848
+ router.post(
849
+ "/auth/passkey/browser-authentication/:requestID/status",
850
+ authLimiter,
851
+ (req, res) => {
852
+ const parsed = BrowserHandoffTokenSchema.safeParse(req.body);
853
+ if (!parsed.success) {
854
+ res.status(400).send({ error: "Invalid browser handoff." });
855
+ return;
856
+ }
857
+ pruneAuthentications();
858
+ pruneBrowserAuthentications();
859
+ const browserRequestID = getParam(req, "requestID");
860
+ const browserPending =
861
+ pendingBrowserAuthentications.get(browserRequestID);
862
+ if (
863
+ !browserPending ||
864
+ !browserTokenMatches(browserPending, parsed.data.token)
865
+ ) {
866
+ res.status(401).send({
867
+ error: "Browser handoff is invalid or expired.",
868
+ });
869
+ return;
870
+ }
871
+ if (
872
+ !pendingAuthentications.has(
873
+ browserPending.authenticationRequestID,
874
+ )
875
+ ) {
876
+ pendingBrowserAuthentications.delete(browserRequestID);
877
+ res.status(401).send({
878
+ error: "Authentication request is invalid or expired.",
879
+ });
880
+ return;
881
+ }
882
+ if (
883
+ browserPending.status !== "response_ready" ||
884
+ !browserPending.response
885
+ ) {
886
+ res.status(202).send({ status: browserPending.status });
887
+ return;
888
+ }
889
+ sendWireResponse(req, res, {
890
+ response: browserPending.response,
891
+ });
892
+ },
893
+ );
894
+
417
895
  router.post("/auth/passkey/begin", authLimiter, async (req, res) => {
418
896
  const parsed = PasskeyAuthStartPayloadSchema.safeParse(req.body);
419
897
  if (!parsed.success) {
@@ -456,19 +934,22 @@ export const getPasskeyRouter = (db: Database) => {
456
934
  const options = await generateAuthenticationOptions({
457
935
  allowCredentials: allowCredentials.filter((c) => c.id.length > 0),
458
936
  rpID,
459
- userVerification: "preferred",
937
+ userVerification: "required",
460
938
  });
461
939
 
462
940
  pruneAuthentications();
463
941
  const requestID = crypto.randomUUID();
942
+ const browserHandoff = createBrowserAuthentication(requestID);
464
943
  pendingAuthentications.set(requestID, {
944
+ browserRequestID: browserHandoff.requestID,
465
945
  challenge: options.challenge,
466
946
  createdAt: Date.now(),
947
+ options,
467
948
  userID: user.userID,
468
949
  });
469
950
 
470
951
  sendWireResponse(req, res, {
471
- options,
952
+ options: { ...options, vexBrowserHandoff: browserHandoff },
472
953
  requestID,
473
954
  });
474
955
  });
@@ -493,6 +974,7 @@ export const getPasskeyRouter = (db: Database) => {
493
974
  }
494
975
  // Single-use.
495
976
  pendingAuthentications.delete(parsed.data.requestID);
977
+ pendingBrowserAuthentications.delete(pending.browserRequestID);
496
978
 
497
979
  // The browser's AuthenticationResponseJSON is opaque to spire
498
980
  // — simplewebauthn does the structural decode + signature
@@ -541,13 +1023,13 @@ export const getPasskeyRouter = (db: Database) => {
541
1023
  expectedChallenge: pending.challenge,
542
1024
  expectedOrigin,
543
1025
  expectedRPID: rpID,
544
- requireUserVerification: false,
1026
+ requireUserVerification: true,
545
1027
  response: assertion,
546
1028
  });
547
1029
  } catch (err: unknown) {
548
- const message = err instanceof Error ? err.message : String(err);
1030
+ logWebAuthnFailure("authentication", err);
549
1031
  res.status(401).send({
550
- error: "Passkey assertion invalid: " + message,
1032
+ error: "Passkey assertion could not be verified.",
551
1033
  });
552
1034
  return;
553
1035
  }
@@ -572,7 +1054,17 @@ export const getPasskeyRouter = (db: Database) => {
572
1054
  return;
573
1055
  }
574
1056
 
575
- await db.markPasskeyUsed(passkeyRow.passkeyID, newCounter);
1057
+ const counterUpdated = await db.markPasskeyUsed(
1058
+ passkeyRow.passkeyID,
1059
+ passkeyRow.signCount,
1060
+ newCounter,
1061
+ );
1062
+ if (!counterUpdated) {
1063
+ res.status(401).send({
1064
+ error: "Passkey assertion was already used.",
1065
+ });
1066
+ return;
1067
+ }
576
1068
 
577
1069
  const user = await db.retrieveUser(pending.userID);
578
1070
  if (!user) {
@@ -593,3 +1085,11 @@ export const getPasskeyRouter = (db: Database) => {
593
1085
 
594
1086
  return router;
595
1087
  };
1088
+
1089
+ function logWebAuthnFailure(ceremony: string, err: unknown): void {
1090
+ const requestId = crypto.randomUUID();
1091
+ const message = err instanceof Error ? err.message : String(err);
1092
+ console.warn(
1093
+ `[spire] WebAuthn ${ceremony} verification failed requestId=${requestId} message=${message}`,
1094
+ );
1095
+ }