@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.
- package/README.md +9 -8
- package/dist/Database.d.ts +20 -11
- package/dist/Database.js +229 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -2
- package/dist/Spire.js +155 -72
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +0 -2
- package/dist/migrations/2026-04-06_initial-schema.js +4 -5
- package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
- package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
- package/dist/migrations/2026-07-14_query-indexes.js +31 -0
- package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
- package/dist/server/avatar.js +33 -6
- package/dist/server/avatar.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +109 -11
- package/dist/server/cliPasskeyPage.js.map +1 -1
- package/dist/server/errors.js +8 -0
- package/dist/server/errors.js.map +1 -1
- package/dist/server/file.js +35 -12
- package/dist/server/file.js.map +1 -1
- package/dist/server/index.d.ts +8 -0
- package/dist/server/index.js +319 -56
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +367 -29
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.js +1 -0
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/password.d.ts +7 -0
- package/dist/server/password.js +58 -0
- package/dist/server/password.js.map +1 -0
- package/dist/server/permissions.d.ts +1 -0
- package/dist/server/permissions.js +11 -2
- package/dist/server/permissions.js.map +1 -1
- package/dist/server/rateLimit.d.ts +11 -0
- package/dist/server/rateLimit.js +43 -4
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/serverIcon.d.ts +10 -0
- package/dist/server/serverIcon.js +158 -0
- package/dist/server/serverIcon.js.map +1 -0
- package/dist/server/user.d.ts +1 -1
- package/dist/server/user.js +40 -9
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +3 -4
- package/dist/types/express.js.map +1 -1
- package/dist/utils/authJwt.d.ts +8 -0
- package/dist/utils/authJwt.js +25 -0
- package/dist/utils/authJwt.js.map +1 -0
- package/dist/utils/loadEnv.js +4 -0
- package/dist/utils/loadEnv.js.map +1 -1
- package/dist/utils/preKeySignature.d.ts +1 -1
- package/dist/utils/preKeySignature.js +7 -11
- package/dist/utils/preKeySignature.js.map +1 -1
- package/package.json +7 -7
- package/src/Database.ts +294 -152
- package/src/Spire.ts +412 -285
- package/src/__tests__/Database.spec.ts +126 -3
- package/src/__tests__/browserPasskeyAuthentication.spec.ts +192 -0
- package/src/__tests__/browserPasskeyRegistration.spec.ts +182 -0
- package/src/__tests__/cliPasskeyPage.spec.ts +6 -0
- package/src/__tests__/connectAuth.spec.ts +146 -4
- package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
- package/src/__tests__/passkeyDevices.spec.ts +94 -0
- package/src/__tests__/passkeys.spec.ts +7 -2
- package/src/__tests__/password.spec.ts +223 -0
- package/src/__tests__/permissions.spec.ts +50 -0
- package/src/__tests__/preKeySignature.spec.ts +20 -3
- package/src/__tests__/serverManagement.spec.ts +262 -0
- package/src/db/schema.ts +0 -2
- package/src/migrations/2026-04-06_initial-schema.ts +4 -5
- package/src/migrations/2026-07-14_query-indexes.ts +34 -0
- package/src/server/avatar.ts +32 -6
- package/src/server/cliPasskeyPage.ts +109 -11
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +494 -139
- package/src/server/passkey.ts +531 -31
- package/src/server/passkeyDevices.ts +1 -0
- package/src/server/password.ts +96 -0
- package/src/server/permissions.ts +20 -2
- package/src/server/rateLimit.ts +47 -4
- package/src/server/serverIcon.ts +199 -0
- package/src/server/user.ts +44 -9
- package/src/types/express.ts +3 -4
- package/src/utils/authJwt.ts +34 -0
- package/src/utils/loadEnv.ts +4 -0
- package/src/utils/preKeySignature.ts +12 -15
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
- package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
package/src/server/passkey.ts
CHANGED
|
@@ -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
|
|
33
|
+
import { z } from "zod";
|
|
32
34
|
|
|
33
35
|
import { JWT_EXPIRY_PASSKEY } from "../Spire.ts";
|
|
34
|
-
import {
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|
-
|
|
289
|
-
if (!req.device && existing.length > 0) {
|
|
446
|
+
if (!req.device || req.device.owner !== userID) {
|
|
290
447
|
res.status(401).send({
|
|
291
|
-
error: "Adding
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
328
|
-
err instanceof Error ? err.message : String(err);
|
|
489
|
+
logWebAuthnFailure("registration", err);
|
|
329
490
|
res.status(400).send({
|
|
330
|
-
error: "Passkey attestation
|
|
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",
|
|
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: "
|
|
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:
|
|
1026
|
+
requireUserVerification: true,
|
|
545
1027
|
response: assertion,
|
|
546
1028
|
});
|
|
547
1029
|
} catch (err: unknown) {
|
|
548
|
-
|
|
1030
|
+
logWebAuthnFailure("authentication", err);
|
|
549
1031
|
res.status(401).send({
|
|
550
|
-
error: "Passkey assertion
|
|
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(
|
|
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
|
+
}
|