@vex-chat/spire 2.6.0 → 3.0.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.
package/src/Spire.ts CHANGED
@@ -53,7 +53,6 @@ import {
53
53
  MailIngressValidationError,
54
54
  validateMailIngress,
55
55
  } from "./server/mailIngress.ts";
56
- import { passkeySecondFactorError } from "./server/passkeySecondFactor.ts";
57
56
  import { authLimiter, devApiKeySkipsRateLimits } from "./server/rateLimit.ts";
58
57
  import { createPendingDeviceEnrollmentRequest } from "./server/user.ts";
59
58
  import { censorUser, getParam, getUser } from "./server/utils.ts";
@@ -62,8 +61,6 @@ import { getJwtSecret } from "./utils/jwtSecret.ts";
62
61
  import { msgpack } from "./utils/msgpack.ts";
63
62
  import { spireXSignOpenAsync } from "./utils/spireXSignOpenAsync.ts";
64
63
 
65
- export { passkeySecondFactorError } from "./server/passkeySecondFactor.ts";
66
-
67
64
  // expiry of regkeys = 24hr
68
65
  export const TOKEN_EXPIRY = 1000 * 60 * 10;
69
66
  export const JWT_EXPIRY = "7d";
@@ -797,22 +794,6 @@ export class Spire extends EventEmitter {
797
794
  .send({ error: "Device owner not found." });
798
795
  }
799
796
 
800
- const passkeyError = await passkeySecondFactorError(
801
- this.db,
802
- user.userID,
803
- req.passkey?.passkeyID,
804
- "Passkey verification does not match this device.",
805
- );
806
- if (passkeyError) {
807
- const body: { error: string; username?: string } = {
808
- error: passkeyError,
809
- };
810
- if (passkeyError === "Passkey verification required.") {
811
- body.username = user.username;
812
- }
813
- return res.status(403).send(body);
814
- }
815
-
816
797
  // Issue short-lived JWT (1 hour, not 7 days)
817
798
  const token = jwt.sign(
818
799
  { user: censorUser(user) },
@@ -1109,19 +1090,6 @@ export class Spire extends EventEmitter {
1109
1090
  await this.db.rehashPassword(userEntry.userID, newHash);
1110
1091
  }
1111
1092
 
1112
- const passkeyError = await passkeySecondFactorError(
1113
- this.db,
1114
- userEntry.userID,
1115
- req.passkey?.passkeyID,
1116
- "Passkey verification does not match this account.",
1117
- );
1118
- if (passkeyError) {
1119
- res.status(403).send({
1120
- error: passkeyError,
1121
- });
1122
- return;
1123
- }
1124
-
1125
1093
  const token = jwt.sign(
1126
1094
  { user: censorUser(userEntry) },
1127
1095
  getJwtSecret(),
@@ -1181,6 +1149,88 @@ export class Spire extends EventEmitter {
1181
1149
  TokenScopes.Register,
1182
1150
  )
1183
1151
  ) {
1152
+ const existingUser = await this.db.retrieveUser(
1153
+ normalizedPayload.username,
1154
+ );
1155
+ if (existingUser) {
1156
+ const existingBySignKey = await this.db.retrieveDevice(
1157
+ normalizedPayload.signKey,
1158
+ );
1159
+ if (existingBySignKey) {
1160
+ res.status(400).send({
1161
+ error: "Public key is already registered.",
1162
+ });
1163
+ return;
1164
+ }
1165
+ let requesterPasskeyID: string | undefined;
1166
+ if (req.passkey?.passkeyID) {
1167
+ const passkey =
1168
+ await this.db.retrievePasskeyInternal(
1169
+ req.passkey.passkeyID,
1170
+ );
1171
+ if (
1172
+ !passkey ||
1173
+ passkey.userID !== existingUser.userID
1174
+ ) {
1175
+ res.status(403).send({
1176
+ error: "Passkey verification does not match this account.",
1177
+ });
1178
+ return;
1179
+ }
1180
+ requesterPasskeyID = req.passkey.passkeyID;
1181
+ } else {
1182
+ if (
1183
+ typeof normalizedPayload.password !==
1184
+ "string" ||
1185
+ normalizedPayload.password.trim().length === 0
1186
+ ) {
1187
+ res.status(401).send({
1188
+ error: "Password is required to add this device.",
1189
+ });
1190
+ return;
1191
+ }
1192
+ const { needsRehash, valid } = await verifyPassword(
1193
+ normalizedPayload.password,
1194
+ existingUser,
1195
+ );
1196
+ if (!valid) {
1197
+ res.sendStatus(401);
1198
+ return;
1199
+ }
1200
+ if (needsRehash) {
1201
+ const newHash = await hashPasswordArgon2(
1202
+ normalizedPayload.password,
1203
+ );
1204
+ await this.db.rehashPassword(
1205
+ existingUser.userID,
1206
+ newHash,
1207
+ );
1208
+ }
1209
+ }
1210
+ const pendingResponse =
1211
+ createPendingDeviceEnrollmentRequest(
1212
+ existingUser.userID,
1213
+ normalizedPayload,
1214
+ this.notify.bind(this),
1215
+ {
1216
+ deferOwnerNotification: true,
1217
+ ...(requesterPasskeyID
1218
+ ? { requesterPasskeyID }
1219
+ : {}),
1220
+ },
1221
+ );
1222
+ res.status(202).send(msgpack.encode(pendingResponse));
1223
+ return;
1224
+ }
1225
+ if (
1226
+ typeof normalizedPayload.password !== "string" ||
1227
+ normalizedPayload.password.trim().length === 0
1228
+ ) {
1229
+ res.status(400).send({
1230
+ error: "Password is required to register a new account.",
1231
+ });
1232
+ return;
1233
+ }
1184
1234
  const [user, err] = await this.db.createUser(
1185
1235
  regKey,
1186
1236
  normalizedPayload,
@@ -1202,57 +1252,9 @@ export class Spire extends EventEmitter {
1202
1252
  errCode === "SQLITE_CONSTRAINT_UNIQUE" ||
1203
1253
  errText.includes("UNIQUE constraint failed");
1204
1254
  if (isUniqueConstraint && usernameConflict) {
1205
- const existingUser = await this.db.retrieveUser(
1206
- normalizedPayload.username,
1207
- );
1208
- if (!existingUser) {
1209
- res.status(400).send({
1210
- error: "Username is already registered.",
1211
- });
1212
- return;
1213
- }
1214
- const existingBySignKey =
1215
- await this.db.retrieveDevice(
1216
- normalizedPayload.signKey,
1217
- );
1218
- if (existingBySignKey) {
1219
- res.status(400).send({
1220
- error: "Public key is already registered.",
1221
- });
1222
- return;
1223
- }
1224
- let requesterPasskeyID: string | undefined;
1225
- if (req.passkey?.passkeyID) {
1226
- const passkeyError =
1227
- await passkeySecondFactorError(
1228
- this.db,
1229
- existingUser.userID,
1230
- req.passkey.passkeyID,
1231
- "Passkey verification does not match this account.",
1232
- );
1233
- if (passkeyError) {
1234
- res.status(403).send({
1235
- error: passkeyError,
1236
- });
1237
- return;
1238
- }
1239
- requesterPasskeyID = req.passkey.passkeyID;
1240
- }
1241
- const pendingResponse =
1242
- createPendingDeviceEnrollmentRequest(
1243
- existingUser.userID,
1244
- normalizedPayload,
1245
- this.notify.bind(this),
1246
- {
1247
- deferOwnerNotification: true,
1248
- ...(requesterPasskeyID
1249
- ? { requesterPasskeyID }
1250
- : {}),
1251
- },
1252
- );
1253
- res.status(202).send(
1254
- msgpack.encode(pendingResponse),
1255
- );
1255
+ res.status(400).send({
1256
+ error: "Username is already registered.",
1257
+ });
1256
1258
  return;
1257
1259
  }
1258
1260
  if (isUniqueConstraint && signKeyConflict) {
@@ -81,6 +81,36 @@ describe("Database", () => {
81
81
  dbType: "sqlite3mem",
82
82
  };
83
83
 
84
+ describe("createUser", () => {
85
+ it("requires a password for new accounts", async () => {
86
+ expect.assertions(3);
87
+ const provider = new Database(options);
88
+ await new Promise<void>((resolve, reject) => {
89
+ provider.once("ready", () => {
90
+ void (async () => {
91
+ try {
92
+ const [user, err] = await provider.createUser(
93
+ new Uint8Array(16),
94
+ devicePayload("desktop", "b".repeat(64)),
95
+ );
96
+ expect(user).toBeNull();
97
+ expect(err).toBeInstanceOf(Error);
98
+ expect(err?.message).toBe(
99
+ "Password is required to register a new account.",
100
+ );
101
+ await provider.close();
102
+ resolve();
103
+ } catch (e: unknown) {
104
+ reject(
105
+ e instanceof Error ? e : new Error(String(e)),
106
+ );
107
+ }
108
+ })();
109
+ });
110
+ });
111
+ });
112
+ });
113
+
84
114
  describe("saveOTK", () => {
85
115
  it("takes a userId and one time key, adds a keyId and saves it to oneTimeKey table", async () => {
86
116
  expect.assertions(1);
@@ -0,0 +1,120 @@
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 { Device, User } from "@vex-chat/types";
9
+ import type { Server } from "node:http";
10
+
11
+ import express from "express";
12
+
13
+ import { xSignAsync, xSignKeyPair, XUtils } from "@vex-chat/crypto";
14
+ import { TokenScopes } from "@vex-chat/types";
15
+
16
+ import jwt from "jsonwebtoken";
17
+ import { parse as uuidParse } from "uuid";
18
+ import { afterEach, describe, expect, it } from "vitest";
19
+
20
+ import { initApp } from "../server/index.ts";
21
+ import { getJwtSecret } from "../utils/jwtSecret.ts";
22
+ import { msgpack } from "../utils/msgpack.ts";
23
+
24
+ const originalJwtSecret = process.env["JWT_SECRET"];
25
+
26
+ const user: User = {
27
+ lastSeen: new Date(0).toISOString(),
28
+ userID: "user-a",
29
+ username: "alice",
30
+ };
31
+
32
+ describe("device connect auth", () => {
33
+ afterEach(() => {
34
+ if (originalJwtSecret === undefined) {
35
+ delete process.env["JWT_SECRET"];
36
+ } else {
37
+ process.env["JWT_SECRET"] = originalJwtSecret;
38
+ }
39
+ });
40
+
41
+ it("allows a password-created account to connect before passkey enrollment", async () => {
42
+ process.env["JWT_SECRET"] = "test-jwt-secret";
43
+ const connectToken = "93ce482b-a0f2-4f6e-b1df-3aed61073552";
44
+ const signKeys = xSignKeyPair();
45
+ const device: Device = {
46
+ deleted: false,
47
+ deviceID: "device-a",
48
+ lastLogin: new Date(0).toISOString(),
49
+ name: "desktop",
50
+ owner: user.userID,
51
+ signKey: XUtils.encodeHex(signKeys.publicKey),
52
+ };
53
+ const db = {
54
+ retrieveDevice: (deviceID: string) =>
55
+ Promise.resolve(deviceID === device.deviceID ? device : null),
56
+ } as unknown as Database;
57
+ const app = express();
58
+ initApp(
59
+ app,
60
+ db,
61
+ (key, scope) =>
62
+ key === connectToken && scope === TokenScopes.Connect,
63
+ xSignKeyPair(),
64
+ () => {},
65
+ );
66
+ const server = await listen(app);
67
+
68
+ try {
69
+ const address = server.address();
70
+ if (!address || typeof address === "string") {
71
+ throw new Error("Expected TCP listener.");
72
+ }
73
+ const signed = await xSignAsync(
74
+ Uint8Array.from(uuidParse(connectToken)),
75
+ signKeys.secretKey,
76
+ );
77
+ const token = jwt.sign({ user }, getJwtSecret());
78
+
79
+ const res = await fetch(
80
+ `http://127.0.0.1:${String(address.port)}/device/${device.deviceID}/connect`,
81
+ {
82
+ body: msgpack.encode({ signed }),
83
+ headers: {
84
+ Authorization: `Bearer ${token}`,
85
+ "Content-Type": "application/msgpack",
86
+ },
87
+ method: "POST",
88
+ },
89
+ );
90
+
91
+ expect(res.status).toBe(200);
92
+ const body = msgpack.decode(
93
+ new Uint8Array(await res.arrayBuffer()),
94
+ ) as { deviceToken?: unknown };
95
+ expect(typeof body.deviceToken).toBe("string");
96
+ } finally {
97
+ await close(server);
98
+ }
99
+ });
100
+ });
101
+
102
+ function close(server: Server): Promise<void> {
103
+ return new Promise((resolve, reject) => {
104
+ server.close((err) => {
105
+ if (err) {
106
+ reject(err);
107
+ return;
108
+ }
109
+ resolve();
110
+ });
111
+ });
112
+ }
113
+
114
+ function listen(app: express.Application): Promise<Server> {
115
+ return new Promise((resolve) => {
116
+ const server = app.listen(0, "127.0.0.1", () => {
117
+ resolve(server);
118
+ });
119
+ });
120
+ }
@@ -9,7 +9,6 @@ import type { SpireOptions } from "../Spire.ts";
9
9
  import { describe, expect, it, vi } from "vitest";
10
10
 
11
11
  import { Database } from "../Database.ts";
12
- import { passkeySecondFactorError } from "../Spire.ts";
13
12
 
14
13
  vi.mock("uuid", () => ({
15
14
  parse: (s: string) => {
@@ -203,52 +202,9 @@ describe("Database passkeys", () => {
203
202
  });
204
203
  });
205
204
 
206
- describe("passkeySecondFactorError", () => {
207
- it("allows bootstrap login when the account has no passkeys", async () => {
205
+ describe("device passkey approval records", () => {
206
+ it("records passkey-backed device approval metadata", async () => {
208
207
  expect.assertions(1);
209
- await withDb(async (db) => {
210
- await expect(
211
- passkeySecondFactorError(db, userID, undefined, "mismatch"),
212
- ).resolves.toBeNull();
213
- });
214
- });
215
-
216
- it("requires a matching passkey once the account has one", async () => {
217
- expect.assertions(3);
218
- await withDb(async (db) => {
219
- const created = await db.createPasskey(
220
- userID,
221
- samplePasskey.name,
222
- samplePasskey.credentialID,
223
- samplePasskey.publicKeyHex,
224
- samplePasskey.algorithm,
225
- samplePasskey.transports,
226
- );
227
-
228
- await expect(
229
- passkeySecondFactorError(db, userID, undefined, "mismatch"),
230
- ).resolves.toBe("Passkey verification required.");
231
- await expect(
232
- passkeySecondFactorError(
233
- db,
234
- userID,
235
- "not-this-passkey",
236
- "mismatch",
237
- ),
238
- ).resolves.toBe("mismatch");
239
- await expect(
240
- passkeySecondFactorError(
241
- db,
242
- userID,
243
- created.passkeyID,
244
- "mismatch",
245
- ),
246
- ).resolves.toBeNull();
247
- });
248
- });
249
-
250
- it("does not treat device approval as future passkey verification", async () => {
251
- expect.assertions(3);
252
208
  await withDb(async (db) => {
253
209
  const created = await db.createPasskey(
254
210
  userID,
@@ -264,15 +220,9 @@ describe("Database passkeys", () => {
264
220
  { approvedByPasskeyID: created.passkeyID },
265
221
  );
266
222
 
267
- await expect(
268
- passkeySecondFactorError(db, userID, undefined, "mismatch"),
269
- ).resolves.toBe("Passkey verification required.");
270
223
  await expect(
271
224
  db.isDevicePasskeyApproved(userID, device.deviceID),
272
225
  ).resolves.toBe(true);
273
- await expect(
274
- passkeySecondFactorError(db, userID, undefined, "mismatch"),
275
- ).resolves.toBe("Passkey verification required.");
276
226
  });
277
227
  });
278
228
 
@@ -167,7 +167,7 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
167
167
 
168
168
  if (mode === "register") {
169
169
  titleEl.textContent = "Create a passkey for Vex.";
170
- copyEl.textContent = "This adds the first passkey required by your new CLI account.";
170
+ copyEl.textContent = "This adds a passkey to your Vex account.";
171
171
  action.textContent = "Create passkey";
172
172
  setStatus("Ready to create passkey.");
173
173
  } else {
@@ -46,11 +46,7 @@ import {
46
46
  hasPermission,
47
47
  userHasPermission,
48
48
  } from "./permissions.ts";
49
- import {
50
- devApiKeyMatches,
51
- globalLimiter,
52
- keyBundleLimiter,
53
- } from "./rateLimit.ts";
49
+ import { globalLimiter, keyBundleLimiter } from "./rateLimit.ts";
54
50
  import { getUserRouter } from "./user.ts";
55
51
  import { censorUser, getParam, getUser } from "./utils.ts";
56
52
  import { getWellKnownRouter } from "./wellKnown.ts";
@@ -688,17 +684,6 @@ export const initApp = (
688
684
  res.sendStatus(401);
689
685
  return;
690
686
  }
691
- const passkeys = await db.retrievePasskeysByUser(device.owner);
692
- // The CLI stress harness cannot perform a WebAuthn ceremony.
693
- // Keep normal clients gated, but let the existing dev/load-test key
694
- // exercise device connect on disposable accounts.
695
- if (passkeys.length === 0 && !devApiKeyMatches(req)) {
696
- res.status(403).send({
697
- error: "A passkey must be registered before this device can connect.",
698
- });
699
- return;
700
- }
701
-
702
687
  const regKey = await spireXSignOpenAsync(
703
688
  signed,
704
689
  XUtils.decodeHex(device.signKey),
@@ -204,10 +204,9 @@ export const getPasskeyRouter = (db: Database) => {
204
204
  }
205
205
 
206
206
  const existing = await db.retrievePasskeysByUser(userID);
207
- // A freshly registered account has a bearer token but cannot
208
- // get a device token until its first passkey exists. Allow
209
- // exactly that bootstrap case; every later passkey addition
210
- // must come from an authenticated device session.
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.
211
210
  if (!req.device && existing.length > 0) {
212
211
  res.status(401).send({
213
212
  error: "Adding another passkey requires an authenticated device.",
@@ -408,13 +407,6 @@ export const getPasskeyRouter = (db: Database) => {
408
407
  res.sendStatus(404);
409
408
  return;
410
409
  }
411
- const passkeys = await db.retrievePasskeysByUser(userID);
412
- if (passkeys.length <= 1) {
413
- res.status(400).send({
414
- error: "You can't delete your last passkey.",
415
- });
416
- return;
417
- }
418
410
  await db.deletePasskey(passkeyID);
419
411
  res.sendStatus(200);
420
412
  },
@@ -28,7 +28,6 @@ import { msgpack } from "../utils/msgpack.ts";
28
28
  import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
29
29
 
30
30
  import { AppError } from "./errors.ts";
31
- import { passkeySecondFactorError } from "./passkeySecondFactor.ts";
32
31
  import { censorUser, getParam, getUser } from "./utils.ts";
33
32
  import { buildAndroidApkKeyHashOrigins } from "./wellKnown.ts";
34
33
 
@@ -914,14 +913,13 @@ export const getUserRouter = (
914
913
 
915
914
  let requesterPasskeyID: string | undefined;
916
915
  if (req.passkey?.passkeyID) {
917
- const passkeyError = await passkeySecondFactorError(
918
- db,
919
- userDetails.userID,
916
+ const passkey = await db.retrievePasskeyInternal(
920
917
  req.passkey.passkeyID,
921
- "Passkey verification does not match this account.",
922
918
  );
923
- if (passkeyError) {
924
- res.status(403).send({ error: passkeyError });
919
+ if (!passkey || passkey.userID !== userDetails.userID) {
920
+ res.status(403).send({
921
+ error: "Passkey verification does not match this account.",
922
+ });
925
923
  return;
926
924
  }
927
925
  requesterPasskeyID = req.passkey.passkeyID;
@@ -1032,15 +1030,15 @@ export const getUserRouter = (
1032
1030
  // Older clients may still satisfy this with an approval-side passkey.
1033
1031
  const approvedByPasskeyID =
1034
1032
  pending.requesterPasskeyID ?? req.passkey?.passkeyID;
1035
- const passkeyError = await passkeySecondFactorError(
1036
- db,
1037
- userID,
1038
- approvedByPasskeyID,
1039
- "Passkey verification does not match this account.",
1040
- );
1041
- if (passkeyError) {
1042
- res.status(403).send({ error: passkeyError });
1043
- return;
1033
+ if (approvedByPasskeyID) {
1034
+ const passkey =
1035
+ await db.retrievePasskeyInternal(approvedByPasskeyID);
1036
+ if (!passkey || passkey.userID !== userID) {
1037
+ res.status(403).send({
1038
+ error: "Passkey verification does not match this account.",
1039
+ });
1040
+ return;
1041
+ }
1044
1042
  }
1045
1043
 
1046
1044
  const opened = await spireXSignOpenAsync(
@@ -1,7 +0,0 @@
1
- /**
2
- * Copyright (c) 2020-2026 Vex Heavy Industries LLC
3
- * Licensed under AGPL-3.0. See LICENSE for details.
4
- * Commercial licenses available at vex.wtf
5
- */
6
- import type { Database } from "../Database.ts";
7
- export declare function passkeySecondFactorError(db: Database, userID: string, passkeyID: string | undefined, mismatchError: string): Promise<null | string>;
@@ -1,20 +0,0 @@
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
- export async function passkeySecondFactorError(db, userID, passkeyID, mismatchError) {
7
- const passkeys = await db.retrievePasskeysByUser(userID);
8
- if (passkeys.length === 0) {
9
- return null;
10
- }
11
- if (!passkeyID) {
12
- return "Passkey verification required.";
13
- }
14
- const passkey = await db.retrievePasskeyInternal(passkeyID);
15
- if (!passkey || passkey.userID !== userID) {
16
- return mismatchError;
17
- }
18
- return null;
19
- }
20
- //# sourceMappingURL=passkeySecondFactor.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"passkeySecondFactor.js","sourceRoot":"","sources":["../../src/server/passkeySecondFactor.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAC1C,EAAY,EACZ,MAAc,EACd,SAA6B,EAC7B,aAAqB;IAErB,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,sBAAsB,CAAC,MAAM,CAAC,CAAC;IACzD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,OAAO,IAAI,CAAC;IAChB,CAAC;IACD,IAAI,CAAC,SAAS,EAAE,CAAC;QACb,OAAO,gCAAgC,CAAC;IAC5C,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,uBAAuB,CAAC,SAAS,CAAC,CAAC;IAC5D,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QACxC,OAAO,aAAa,CAAC;IACzB,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC"}
@@ -1,27 +0,0 @@
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
-
9
- export async function passkeySecondFactorError(
10
- db: Database,
11
- userID: string,
12
- passkeyID: string | undefined,
13
- mismatchError: string,
14
- ): Promise<null | string> {
15
- const passkeys = await db.retrievePasskeysByUser(userID);
16
- if (passkeys.length === 0) {
17
- return null;
18
- }
19
- if (!passkeyID) {
20
- return "Passkey verification required.";
21
- }
22
- const passkey = await db.retrievePasskeyInternal(passkeyID);
23
- if (!passkey || passkey.userID !== userID) {
24
- return mismatchError;
25
- }
26
- return null;
27
- }