@vex-chat/spire 4.0.0 → 4.1.1

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.
@@ -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,6 +30,7 @@ import {
28
30
  verifyAuthenticationResponse,
29
31
  verifyRegistrationResponse,
30
32
  } from "@simplewebauthn/server";
33
+ import { z } from "zod";
31
34
 
32
35
  import { JWT_EXPIRY_PASSKEY } from "../Spire.ts";
33
36
  import { signAuthJwt } from "../utils/authJwt.ts";
@@ -42,17 +45,50 @@ import { protect, protectAnyAuth } from "./index.ts";
42
45
 
43
46
  const REGISTRATION_TTL_MS = 5 * 60 * 1000; // 5 min
44
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;
45
50
  // Cap each user's passkey count so a compromised JWT can't fill the
46
51
  // table. WebAuthn-style apps typically allow ~20; we go conservative.
47
52
  const MAX_PASSKEYS_PER_USER = 10;
48
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
+
49
61
  interface PendingAuthentication {
62
+ browserRequestID: string;
50
63
  challenge: string;
51
64
  createdAt: number;
65
+ options: AuthenticationOptions;
66
+ userID: string;
67
+ }
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;
52
86
  userID: string;
87
+ username: string;
53
88
  }
54
89
 
55
90
  interface PendingRegistration {
91
+ browserRequestID: string;
56
92
  challenge: string;
57
93
  createdAt: number;
58
94
  deviceID: string;
@@ -62,6 +98,102 @@ interface PendingRegistration {
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
  }
@@ -240,7 +389,15 @@ export const getPasskeyRouter = (db: Database) => {
240
389
 
241
390
  pruneRegistrations();
242
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
+ });
243
399
  pendingRegistrations.set(requestID, {
400
+ browserRequestID: browserHandoff.requestID,
244
401
  challenge: options.challenge,
245
402
  createdAt: Date.now(),
246
403
  deviceID: req.device.deviceID,
@@ -254,7 +411,11 @@ export const getPasskeyRouter = (db: Database) => {
254
411
  // SimpleWebAuthn). The wire shape is identical — both
255
412
  // sides hand the JSON straight to navigator.credentials.
256
413
  sendWireResponse(req, res, {
257
- 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 },
258
419
  requestID,
259
420
  });
260
421
  },
@@ -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
 
@@ -419,6 +581,317 @@ export const getPasskeyRouter = (db: Database) => {
419
581
 
420
582
  // ── Public passkey login ───────────────────────────────────────────
421
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
+
422
895
  router.post("/auth/passkey/begin", authLimiter, async (req, res) => {
423
896
  const parsed = PasskeyAuthStartPayloadSchema.safeParse(req.body);
424
897
  if (!parsed.success) {
@@ -466,14 +939,17 @@ export const getPasskeyRouter = (db: Database) => {
466
939
 
467
940
  pruneAuthentications();
468
941
  const requestID = crypto.randomUUID();
942
+ const browserHandoff = createBrowserAuthentication(requestID);
469
943
  pendingAuthentications.set(requestID, {
944
+ browserRequestID: browserHandoff.requestID,
470
945
  challenge: options.challenge,
471
946
  createdAt: Date.now(),
947
+ options,
472
948
  userID: user.userID,
473
949
  });
474
950
 
475
951
  sendWireResponse(req, res, {
476
- options,
952
+ options: { ...options, vexBrowserHandoff: browserHandoff },
477
953
  requestID,
478
954
  });
479
955
  });
@@ -498,6 +974,7 @@ export const getPasskeyRouter = (db: Database) => {
498
974
  }
499
975
  // Single-use.
500
976
  pendingAuthentications.delete(parsed.data.requestID);
977
+ pendingBrowserAuthentications.delete(pending.browserRequestID);
501
978
 
502
979
  // The browser's AuthenticationResponseJSON is opaque to spire
503
980
  // — simplewebauthn does the structural decode + signature