@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/dist/Database.js +6 -6
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +0 -1
- package/dist/Spire.js +58 -50
- package/dist/Spire.js.map +1 -1
- package/dist/server/cliPasskeyPage.js +1 -1
- package/dist/server/index.js +1 -11
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +3 -11
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/user.js +13 -8
- package/dist/server/user.js.map +1 -1
- package/package.json +3 -3
- package/src/Database.ts +10 -8
- package/src/Spire.ts +85 -83
- package/src/__tests__/Database.spec.ts +30 -0
- package/src/__tests__/connectAuth.spec.ts +120 -0
- package/src/__tests__/passkeys.spec.ts +2 -52
- package/src/server/cliPasskeyPage.ts +1 -1
- package/src/server/index.ts +1 -16
- package/src/server/passkey.ts +3 -11
- package/src/server/user.ts +14 -16
- package/dist/server/passkeySecondFactor.d.ts +0 -7
- package/dist/server/passkeySecondFactor.js +0 -20
- package/dist/server/passkeySecondFactor.js.map +0 -1
- package/src/server/passkeySecondFactor.ts +0 -27
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
|
-
|
|
1206
|
-
|
|
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("
|
|
207
|
-
it("
|
|
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
|
|
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 {
|
package/src/server/index.ts
CHANGED
|
@@ -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),
|
package/src/server/passkey.ts
CHANGED
|
@@ -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
|
|
208
|
-
//
|
|
209
|
-
//
|
|
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
|
},
|
package/src/server/user.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
924
|
-
res.status(403).send({
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
}
|