@vex-chat/spire 3.0.0 → 4.0.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 +12 -11
- package/dist/Database.js +161 -118
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +4 -3
- package/dist/Spire.js +176 -92
- 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 +30 -4
- package/dist/server/avatar.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 +157 -41
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +41 -33
- 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/{passkeySecondFactor.d.ts → password.d.ts} +1 -1
- 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/user.d.ts +1 -1
- package/dist/server/user.js +53 -17
- 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 +211 -152
- package/src/Spire.ts +418 -294
- package/src/__tests__/Database.spec.ts +126 -3
- 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 +9 -54
- 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/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 +29 -4
- package/src/server/errors.ts +7 -0
- package/src/server/file.ts +34 -13
- package/src/server/index.ts +286 -111
- package/src/server/passkey.ts +51 -35
- 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/user.ts +58 -25
- 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/dist/server/passkeySecondFactor.js +0 -20
- package/dist/server/passkeySecondFactor.js.map +0 -1
- package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
- package/src/server/passkeySecondFactor.ts +0 -27
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { SpireOptions } from "../Spire.ts";
|
|
8
|
-
import type {
|
|
8
|
+
import type { MailWS, PreKeysWS, RegistrationPayload } from "@vex-chat/types";
|
|
9
9
|
|
|
10
10
|
import { XUtils } from "@vex-chat/crypto";
|
|
11
11
|
import { MailType } from "@vex-chat/types";
|
|
@@ -13,7 +13,11 @@ import { MailType } from "@vex-chat/types";
|
|
|
13
13
|
import * as uuid from "uuid";
|
|
14
14
|
import { describe, expect, it, vi } from "vitest";
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
Database,
|
|
18
|
+
MAX_ACTIVE_DEVICES_PER_USER,
|
|
19
|
+
validateAccountPassword,
|
|
20
|
+
} from "../Database.ts";
|
|
17
21
|
|
|
18
22
|
// vi.mock is hoisted above all imports automatically.
|
|
19
23
|
// Minimal stubs for uuid functions used by spire src: v4, parse, stringify.
|
|
@@ -67,8 +71,9 @@ describe("Database", () => {
|
|
|
67
71
|
const devicePayload = (
|
|
68
72
|
deviceName: string,
|
|
69
73
|
signKey: string,
|
|
70
|
-
):
|
|
74
|
+
): RegistrationPayload => ({
|
|
71
75
|
deviceName,
|
|
76
|
+
intent: "create-account",
|
|
72
77
|
preKey: testSQLPreKey.publicKey,
|
|
73
78
|
preKeyIndex: 1,
|
|
74
79
|
preKeySignature: testSQLPreKey.signature,
|
|
@@ -81,6 +86,32 @@ describe("Database", () => {
|
|
|
81
86
|
dbType: "sqlite3mem",
|
|
82
87
|
};
|
|
83
88
|
|
|
89
|
+
describe("account password policy", () => {
|
|
90
|
+
it("accepts long passphrases without composition requirements", () => {
|
|
91
|
+
expect(
|
|
92
|
+
validateAccountPassword("four simple words make a password"),
|
|
93
|
+
).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("rejects short, common, repeated, and username-matching values", () => {
|
|
97
|
+
expect(validateAccountPassword("thirteen-char!")).toContain(
|
|
98
|
+
"at least 15",
|
|
99
|
+
);
|
|
100
|
+
expect(validateAccountPassword("passwordpassword")).toBe(
|
|
101
|
+
"Choose a less common password.",
|
|
102
|
+
);
|
|
103
|
+
expect(validateAccountPassword("z".repeat(20))).toBe(
|
|
104
|
+
"Choose a less common password.",
|
|
105
|
+
);
|
|
106
|
+
expect(
|
|
107
|
+
validateAccountPassword(
|
|
108
|
+
"long-account-name",
|
|
109
|
+
"Long-Account-Name",
|
|
110
|
+
),
|
|
111
|
+
).toBe("Choose a less common password.");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
84
115
|
describe("createUser", () => {
|
|
85
116
|
it("requires a password for new accounts", async () => {
|
|
86
117
|
expect.assertions(3);
|
|
@@ -109,6 +140,98 @@ describe("Database", () => {
|
|
|
109
140
|
});
|
|
110
141
|
});
|
|
111
142
|
});
|
|
143
|
+
|
|
144
|
+
it("rolls back the user when initial device creation fails", async () => {
|
|
145
|
+
expect.assertions(5);
|
|
146
|
+
const provider = new Database(options);
|
|
147
|
+
await new Promise<void>((resolve, reject) => {
|
|
148
|
+
provider.once("ready", () => {
|
|
149
|
+
void (async () => {
|
|
150
|
+
try {
|
|
151
|
+
const signKey = "c".repeat(64);
|
|
152
|
+
const [first, firstError] =
|
|
153
|
+
await provider.createUser(
|
|
154
|
+
new Uint8Array(16).fill(1),
|
|
155
|
+
{
|
|
156
|
+
...devicePayload("desktop", signKey),
|
|
157
|
+
password:
|
|
158
|
+
"correct horse battery staple",
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
expect(firstError).toBeNull();
|
|
162
|
+
expect(first).not.toBeNull();
|
|
163
|
+
|
|
164
|
+
const [second, secondError] =
|
|
165
|
+
await provider.createUser(
|
|
166
|
+
new Uint8Array(16).fill(2),
|
|
167
|
+
{
|
|
168
|
+
...devicePayload("mobile", signKey),
|
|
169
|
+
password:
|
|
170
|
+
"correct horse battery staple",
|
|
171
|
+
username: "bob",
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
expect(secondError).toBeInstanceOf(Error);
|
|
175
|
+
expect(second).toBeNull();
|
|
176
|
+
await expect(
|
|
177
|
+
provider.retrieveUser("bob"),
|
|
178
|
+
).resolves.toBeNull();
|
|
179
|
+
await provider.close();
|
|
180
|
+
resolve();
|
|
181
|
+
} catch (e: unknown) {
|
|
182
|
+
await provider.close().catch(() => undefined);
|
|
183
|
+
reject(
|
|
184
|
+
e instanceof Error ? e : new Error(String(e)),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
})();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("createDevice", () => {
|
|
194
|
+
it("bounds active device clusters", async () => {
|
|
195
|
+
const provider = new Database(options);
|
|
196
|
+
await new Promise<void>((resolve, reject) => {
|
|
197
|
+
provider.once("ready", () => {
|
|
198
|
+
void (async () => {
|
|
199
|
+
try {
|
|
200
|
+
for (
|
|
201
|
+
let index = 0;
|
|
202
|
+
index < MAX_ACTIVE_DEVICES_PER_USER;
|
|
203
|
+
index += 1
|
|
204
|
+
) {
|
|
205
|
+
await provider.createDevice(
|
|
206
|
+
userID,
|
|
207
|
+
devicePayload(
|
|
208
|
+
`device-${String(index)}`,
|
|
209
|
+
index.toString(16).padStart(64, "0"),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await expect(
|
|
215
|
+
provider.createDevice(
|
|
216
|
+
userID,
|
|
217
|
+
devicePayload(
|
|
218
|
+
"one-too-many",
|
|
219
|
+
"f".repeat(64),
|
|
220
|
+
),
|
|
221
|
+
),
|
|
222
|
+
).rejects.toThrow("limited to 20 active devices");
|
|
223
|
+
await provider.close();
|
|
224
|
+
resolve();
|
|
225
|
+
} catch (e: unknown) {
|
|
226
|
+
await provider.close().catch(() => undefined);
|
|
227
|
+
reject(
|
|
228
|
+
e instanceof Error ? e : new Error(String(e)),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
})();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
|
112
235
|
});
|
|
113
236
|
|
|
114
237
|
describe("saveOTK", () => {
|
|
@@ -11,14 +11,18 @@ import type { Server } from "node:http";
|
|
|
11
11
|
import express from "express";
|
|
12
12
|
|
|
13
13
|
import { xSignAsync, xSignKeyPair, XUtils } from "@vex-chat/crypto";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
MAX_FILE_UPLOAD_BASE64_LENGTH,
|
|
16
|
+
MAX_FILE_UPLOAD_BYTES,
|
|
17
|
+
MAX_FILE_UPLOAD_ENCODED_BODY_BYTES,
|
|
18
|
+
TokenScopes,
|
|
19
|
+
} from "@vex-chat/types";
|
|
15
20
|
|
|
16
|
-
import jwt from "jsonwebtoken";
|
|
17
21
|
import { parse as uuidParse } from "uuid";
|
|
18
22
|
import { afterEach, describe, expect, it } from "vitest";
|
|
19
23
|
|
|
20
24
|
import { initApp } from "../server/index.ts";
|
|
21
|
-
import {
|
|
25
|
+
import { signAuthJwt } from "../utils/authJwt.ts";
|
|
22
26
|
import { msgpack } from "../utils/msgpack.ts";
|
|
23
27
|
|
|
24
28
|
const originalJwtSecret = process.env["JWT_SECRET"];
|
|
@@ -74,7 +78,7 @@ describe("device connect auth", () => {
|
|
|
74
78
|
Uint8Array.from(uuidParse(connectToken)),
|
|
75
79
|
signKeys.secretKey,
|
|
76
80
|
);
|
|
77
|
-
const token =
|
|
81
|
+
const token = signAuthJwt({ scope: "user", user }, "5m");
|
|
78
82
|
|
|
79
83
|
const res = await fetch(
|
|
80
84
|
`http://127.0.0.1:${String(address.port)}/device/${device.deviceID}/connect`,
|
|
@@ -97,6 +101,144 @@ describe("device connect auth", () => {
|
|
|
97
101
|
await close(server);
|
|
98
102
|
}
|
|
99
103
|
});
|
|
104
|
+
|
|
105
|
+
it("does not let a passkey-scoped token enter regular account routes", async () => {
|
|
106
|
+
process.env["JWT_SECRET"] = "test-jwt-secret";
|
|
107
|
+
const db = {
|
|
108
|
+
retrievePasskeyInternal: () => Promise.resolve(null),
|
|
109
|
+
} as unknown as Database;
|
|
110
|
+
const app = express();
|
|
111
|
+
initApp(
|
|
112
|
+
app,
|
|
113
|
+
db,
|
|
114
|
+
() => false,
|
|
115
|
+
xSignKeyPair(),
|
|
116
|
+
() => {},
|
|
117
|
+
);
|
|
118
|
+
const server = await listen(app);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const address = server.address();
|
|
122
|
+
if (!address || typeof address === "string") {
|
|
123
|
+
throw new Error("Expected TCP listener.");
|
|
124
|
+
}
|
|
125
|
+
const token = signAuthJwt(
|
|
126
|
+
{
|
|
127
|
+
passkey: { passkeyID: "passkey-a" },
|
|
128
|
+
scope: "passkey",
|
|
129
|
+
user,
|
|
130
|
+
},
|
|
131
|
+
"5m",
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const res = await fetch(
|
|
135
|
+
`http://127.0.0.1:${String(address.port)}/server/server-a`,
|
|
136
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(res.status).toBe(401);
|
|
140
|
+
} finally {
|
|
141
|
+
await close(server);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("limits the default development CORS policy to local app origins", async () => {
|
|
146
|
+
const originalCorsOrigins = process.env["CORS_ORIGINS"];
|
|
147
|
+
delete process.env["CORS_ORIGINS"];
|
|
148
|
+
const app = express();
|
|
149
|
+
initApp(
|
|
150
|
+
app,
|
|
151
|
+
{} as Database,
|
|
152
|
+
() => false,
|
|
153
|
+
xSignKeyPair(),
|
|
154
|
+
() => {},
|
|
155
|
+
);
|
|
156
|
+
const server = await listen(app);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const address = server.address();
|
|
160
|
+
if (!address || typeof address === "string") {
|
|
161
|
+
throw new Error("Expected TCP listener.");
|
|
162
|
+
}
|
|
163
|
+
const url = `http://127.0.0.1:${String(address.port)}/server/server-a`;
|
|
164
|
+
const preflightHeaders = {
|
|
165
|
+
"Access-Control-Request-Method": "GET",
|
|
166
|
+
};
|
|
167
|
+
const blocked = await fetch(url, {
|
|
168
|
+
headers: {
|
|
169
|
+
...preflightHeaders,
|
|
170
|
+
Origin: "https://attacker.example",
|
|
171
|
+
},
|
|
172
|
+
method: "OPTIONS",
|
|
173
|
+
});
|
|
174
|
+
const allowed = await fetch(url, {
|
|
175
|
+
headers: {
|
|
176
|
+
...preflightHeaders,
|
|
177
|
+
Origin: "http://localhost:5180",
|
|
178
|
+
},
|
|
179
|
+
method: "OPTIONS",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(
|
|
183
|
+
blocked.headers.get("access-control-allow-origin"),
|
|
184
|
+
).toBeNull();
|
|
185
|
+
expect(allowed.headers.get("access-control-allow-origin")).toBe(
|
|
186
|
+
"http://localhost:5180",
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
if (originalCorsOrigins === undefined) {
|
|
190
|
+
delete process.env["CORS_ORIGINS"];
|
|
191
|
+
} else {
|
|
192
|
+
process.env["CORS_ORIGINS"] = originalCorsOrigins;
|
|
193
|
+
}
|
|
194
|
+
await close(server);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("accepts a fallback upload body above the default parser limit", async () => {
|
|
199
|
+
expect(MAX_FILE_UPLOAD_BASE64_LENGTH).toBe(
|
|
200
|
+
4 * Math.ceil(MAX_FILE_UPLOAD_BYTES / 3),
|
|
201
|
+
);
|
|
202
|
+
expect(MAX_FILE_UPLOAD_ENCODED_BODY_BYTES).toBeGreaterThan(
|
|
203
|
+
MAX_FILE_UPLOAD_BASE64_LENGTH,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const encodedFileLength = 20 * 1024 * 1024 + 1;
|
|
207
|
+
expect(encodedFileLength).toBeLessThan(MAX_FILE_UPLOAD_BASE64_LENGTH);
|
|
208
|
+
const app = express();
|
|
209
|
+
initApp(
|
|
210
|
+
app,
|
|
211
|
+
{} as Database,
|
|
212
|
+
() => false,
|
|
213
|
+
xSignKeyPair(),
|
|
214
|
+
() => {},
|
|
215
|
+
);
|
|
216
|
+
const server = await listen(app);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const address = server.address();
|
|
220
|
+
if (!address || typeof address === "string") {
|
|
221
|
+
throw new Error("Expected TCP listener.");
|
|
222
|
+
}
|
|
223
|
+
const response = await fetch(
|
|
224
|
+
`http://127.0.0.1:${String(address.port)}/file/json`,
|
|
225
|
+
{
|
|
226
|
+
body: msgpack.encode({
|
|
227
|
+
file: "A".repeat(encodedFileLength),
|
|
228
|
+
nonce: "a".repeat(48),
|
|
229
|
+
owner: "device-a",
|
|
230
|
+
}),
|
|
231
|
+
headers: { "Content-Type": "application/msgpack" },
|
|
232
|
+
method: "POST",
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Parsing succeeded; authentication is the next middleware.
|
|
237
|
+
expect(response.status).toBe(401);
|
|
238
|
+
} finally {
|
|
239
|
+
await close(server);
|
|
240
|
+
}
|
|
241
|
+
}, 15_000);
|
|
100
242
|
});
|
|
101
243
|
|
|
102
244
|
function close(server: Server): Promise<void> {
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* Commercial licenses available at vex.wtf
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { Database } from "../Database.ts";
|
|
7
8
|
import type { Device, User } from "@vex-chat/types";
|
|
8
9
|
import type express from "express";
|
|
9
10
|
|
|
10
|
-
import jwt from "jsonwebtoken";
|
|
11
11
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
12
|
|
|
13
|
-
import { createCheckDevice } from "../server/index.ts";
|
|
13
|
+
import { createCheckDevice, createCheckPasskey } from "../server/index.ts";
|
|
14
|
+
import { signAuthJwt } from "../utils/authJwt.ts";
|
|
14
15
|
|
|
15
16
|
const jwtSecret = "test-jwt-secret";
|
|
16
17
|
|
|
@@ -29,6 +30,8 @@ const device: Device = {
|
|
|
29
30
|
signKey: "sign-key-a",
|
|
30
31
|
};
|
|
31
32
|
|
|
33
|
+
const passkeyID = "passkey-a";
|
|
34
|
+
|
|
32
35
|
function makeReq(token: string, reqUser: undefined | User = user) {
|
|
33
36
|
return {
|
|
34
37
|
headers: { "x-device-token": token },
|
|
@@ -37,7 +40,7 @@ function makeReq(token: string, reqUser: undefined | User = user) {
|
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
function makeToken(tokenDevice: Device = device): string {
|
|
40
|
-
return
|
|
43
|
+
return signAuthJwt({ device: tokenDevice, scope: "device" }, "5m");
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
describe("createCheckDevice", () => {
|
|
@@ -102,3 +105,54 @@ describe("createCheckDevice", () => {
|
|
|
102
105
|
expect(req.device).toBeUndefined();
|
|
103
106
|
});
|
|
104
107
|
});
|
|
108
|
+
|
|
109
|
+
describe("createCheckPasskey", () => {
|
|
110
|
+
function makePasskeyReq(reqUser: User = user) {
|
|
111
|
+
return {
|
|
112
|
+
bearerToken: "passkey-token",
|
|
113
|
+
headers: {},
|
|
114
|
+
passkey: { passkeyID },
|
|
115
|
+
user: reqUser,
|
|
116
|
+
} as unknown as express.Request;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function makePasskeyDb(row: null | { userID: string }) {
|
|
120
|
+
return {
|
|
121
|
+
retrievePasskeyInternal: vi.fn(() => Promise.resolve(row)),
|
|
122
|
+
} as unknown as Pick<Database, "retrievePasskeyInternal">;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it("keeps a passkey session while its credential remains bound", async () => {
|
|
126
|
+
const db = makePasskeyDb({ userID: user.userID });
|
|
127
|
+
const req = makePasskeyReq();
|
|
128
|
+
const next = vi.fn();
|
|
129
|
+
|
|
130
|
+
await createCheckPasskey(db)(req, {} as express.Response, next);
|
|
131
|
+
|
|
132
|
+
expect(req.passkey).toEqual({ passkeyID });
|
|
133
|
+
expect(req.user).toEqual(user);
|
|
134
|
+
expect(db.retrievePasskeyInternal).toHaveBeenCalledWith(passkeyID);
|
|
135
|
+
expect(next).toHaveBeenCalledOnce();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("revokes a passkey session as soon as its credential is removed", async () => {
|
|
139
|
+
const db = makePasskeyDb(null);
|
|
140
|
+
const req = makePasskeyReq();
|
|
141
|
+
|
|
142
|
+
await createCheckPasskey(db)(req, {} as express.Response, vi.fn());
|
|
143
|
+
|
|
144
|
+
expect(req.bearerToken).toBeUndefined();
|
|
145
|
+
expect(req.passkey).toBeUndefined();
|
|
146
|
+
expect(req.user).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("rejects a passkey credential bound to a different account", async () => {
|
|
150
|
+
const db = makePasskeyDb({ userID: "user-b" });
|
|
151
|
+
const req = makePasskeyReq();
|
|
152
|
+
|
|
153
|
+
await createCheckPasskey(db)(req, {} as express.Response, vi.fn());
|
|
154
|
+
|
|
155
|
+
expect(req.passkey).toBeUndefined();
|
|
156
|
+
expect(req.user).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { describe, expect, it, vi } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { getPasskeyDeviceRouter } from "../server/passkeyDevices.ts";
|
|
16
|
+
|
|
17
|
+
const user: User = {
|
|
18
|
+
lastSeen: new Date(0).toISOString(),
|
|
19
|
+
userID: "user-a",
|
|
20
|
+
username: "alice",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const device: Device = {
|
|
24
|
+
deleted: false,
|
|
25
|
+
deviceID: "device-a",
|
|
26
|
+
lastLogin: new Date(0).toISOString(),
|
|
27
|
+
name: "desktop",
|
|
28
|
+
owner: user.userID,
|
|
29
|
+
signKey: "a".repeat(64),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("passkey device administration", () => {
|
|
33
|
+
it("disconnects a device immediately after deleting it", async () => {
|
|
34
|
+
const deleteDevice = vi.fn(() => Promise.resolve());
|
|
35
|
+
const db = {
|
|
36
|
+
deleteDevice,
|
|
37
|
+
retrieveDevice: vi.fn((deviceID: string) =>
|
|
38
|
+
Promise.resolve(deviceID === device.deviceID ? device : null),
|
|
39
|
+
),
|
|
40
|
+
} as unknown as Database;
|
|
41
|
+
const disconnectDevices = vi.fn();
|
|
42
|
+
const notify = vi.fn();
|
|
43
|
+
const app = express();
|
|
44
|
+
app.use((req, _res, next) => {
|
|
45
|
+
req.passkey = { passkeyID: "passkey-a" };
|
|
46
|
+
req.user = user;
|
|
47
|
+
next();
|
|
48
|
+
});
|
|
49
|
+
app.use(getPasskeyDeviceRouter(db, notify, disconnectDevices));
|
|
50
|
+
const server = await listen(app);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const address = server.address();
|
|
54
|
+
if (!address || typeof address === "string") {
|
|
55
|
+
throw new Error("Expected TCP listener.");
|
|
56
|
+
}
|
|
57
|
+
const response = await fetch(
|
|
58
|
+
`http://127.0.0.1:${String(address.port)}/user/${user.userID}/passkey/devices/${device.deviceID}`,
|
|
59
|
+
{ method: "DELETE" },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(response.status).toBe(200);
|
|
63
|
+
expect(deleteDevice).toHaveBeenCalledWith(device.deviceID);
|
|
64
|
+
expect(disconnectDevices).toHaveBeenCalledWith([device.deviceID]);
|
|
65
|
+
expect(notify).toHaveBeenCalledWith(
|
|
66
|
+
user.userID,
|
|
67
|
+
"deviceListChanged",
|
|
68
|
+
expect.any(String),
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
await close(server);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function close(server: Server): Promise<void> {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
server.close((error) => {
|
|
79
|
+
if (error) {
|
|
80
|
+
reject(error);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
resolve();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function listen(app: express.Application): Promise<Server> {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const server = app.listen(0, "127.0.0.1", () => {
|
|
91
|
+
resolve(server);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -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) => {
|
|
@@ -181,7 +180,7 @@ describe("Database passkeys", () => {
|
|
|
181
180
|
|
|
182
181
|
describe("markPasskeyUsed", () => {
|
|
183
182
|
it("bumps the signature counter and lastUsedAt timestamp", async () => {
|
|
184
|
-
expect.assertions(
|
|
183
|
+
expect.assertions(5);
|
|
185
184
|
await withDb(async (db) => {
|
|
186
185
|
const created = await db.createPasskey(
|
|
187
186
|
userID,
|
|
@@ -192,63 +191,25 @@ describe("Database passkeys", () => {
|
|
|
192
191
|
samplePasskey.transports,
|
|
193
192
|
);
|
|
194
193
|
|
|
195
|
-
await
|
|
194
|
+
await expect(
|
|
195
|
+
db.markPasskeyUsed(created.passkeyID, 0, 42),
|
|
196
|
+
).resolves.toBe(true);
|
|
196
197
|
const row = await db.retrievePasskeyInternal(created.passkeyID);
|
|
197
198
|
|
|
198
199
|
expect(row).not.toBeNull();
|
|
199
200
|
if (row === null) return;
|
|
200
201
|
expect(row.signCount).toBe(42);
|
|
201
202
|
expect(row.lastUsedAt).not.toBeNull();
|
|
203
|
+
await expect(
|
|
204
|
+
db.markPasskeyUsed(created.passkeyID, 0, 43),
|
|
205
|
+
).resolves.toBe(false);
|
|
202
206
|
});
|
|
203
207
|
});
|
|
204
208
|
});
|
|
205
209
|
|
|
206
|
-
describe("
|
|
207
|
-
it("
|
|
210
|
+
describe("device passkey approval records", () => {
|
|
211
|
+
it("records passkey-backed device approval metadata", async () => {
|
|
208
212
|
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
213
|
await withDb(async (db) => {
|
|
253
214
|
const created = await db.createPasskey(
|
|
254
215
|
userID,
|
|
@@ -264,15 +225,9 @@ describe("Database passkeys", () => {
|
|
|
264
225
|
{ approvedByPasskeyID: created.passkeyID },
|
|
265
226
|
);
|
|
266
227
|
|
|
267
|
-
await expect(
|
|
268
|
-
passkeySecondFactorError(db, userID, undefined, "mismatch"),
|
|
269
|
-
).resolves.toBe("Passkey verification required.");
|
|
270
228
|
await expect(
|
|
271
229
|
db.isDevicePasskeyApproved(userID, device.deviceID),
|
|
272
230
|
).resolves.toBe(true);
|
|
273
|
-
await expect(
|
|
274
|
-
passkeySecondFactorError(db, userID, undefined, "mismatch"),
|
|
275
|
-
).resolves.toBe("Passkey verification required.");
|
|
276
231
|
});
|
|
277
232
|
});
|
|
278
233
|
|