@vex-chat/spire 1.11.5 → 2.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 -7
- package/dist/ClientManager.d.ts +1 -0
- package/dist/ClientManager.js +3 -0
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +11 -0
- package/dist/Database.js +90 -16
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +3 -0
- package/dist/Spire.js +155 -2
- package/dist/Spire.js.map +1 -1
- package/dist/migrations/2026-05-03_passkeys.js +2 -2
- package/dist/migrations/2026-05-03_passkeys.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +18 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +24 -16
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.d.ts +2 -2
- package/dist/server/passkeyDevices.js +14 -15
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/rateLimit.d.ts +6 -2
- package/dist/server/rateLimit.js +13 -10
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +14 -0
- package/dist/server/user.js +48 -0
- package/dist/server/user.js.map +1 -1
- package/dist/utils/loadEnv.d.ts +3 -0
- package/dist/utils/loadEnv.js +63 -3
- package/dist/utils/loadEnv.js.map +1 -1
- package/package.json +5 -5
- package/src/ClientManager.ts +4 -0
- package/src/Database.ts +122 -16
- package/src/Spire.ts +230 -2
- package/src/__tests__/Database.spec.ts +191 -2
- package/src/__tests__/loadEnv.spec.ts +91 -0
- package/src/__tests__/passkeys.spec.ts +46 -0
- package/src/__tests__/rateLimit.spec.ts +60 -0
- package/src/migrations/2026-05-03_passkeys.ts +2 -2
- package/src/server/index.ts +26 -3
- package/src/server/passkey.ts +25 -17
- package/src/server/passkeyDevices.ts +17 -14
- package/src/server/rateLimit.ts +18 -10
- package/src/server/user.ts +66 -0
- package/src/utils/loadEnv.ts +79 -3
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { SpireOptions } from "../Spire.ts";
|
|
8
|
-
import type { MailWS, PreKeysWS } from "@vex-chat/types";
|
|
8
|
+
import type { DevicePayload, MailWS, PreKeysWS } from "@vex-chat/types";
|
|
9
9
|
|
|
10
10
|
import { XUtils } from "@vex-chat/crypto";
|
|
11
11
|
import { MailType } from "@vex-chat/types";
|
|
@@ -64,6 +64,19 @@ describe("Database", () => {
|
|
|
64
64
|
signature,
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
const devicePayload = (
|
|
68
|
+
deviceName: string,
|
|
69
|
+
signKey: string,
|
|
70
|
+
): DevicePayload => ({
|
|
71
|
+
deviceName,
|
|
72
|
+
preKey: testSQLPreKey.publicKey,
|
|
73
|
+
preKeyIndex: 1,
|
|
74
|
+
preKeySignature: testSQLPreKey.signature,
|
|
75
|
+
signed: "00",
|
|
76
|
+
signKey,
|
|
77
|
+
username: "alice",
|
|
78
|
+
});
|
|
79
|
+
|
|
67
80
|
const options: SpireOptions = {
|
|
68
81
|
dbType: "sqlite3mem",
|
|
69
82
|
};
|
|
@@ -156,6 +169,100 @@ describe("Database", () => {
|
|
|
156
169
|
});
|
|
157
170
|
});
|
|
158
171
|
|
|
172
|
+
describe("recoverDevice", () => {
|
|
173
|
+
it("creates a new device and revokes all previous devices for the user", async () => {
|
|
174
|
+
expect.assertions(8);
|
|
175
|
+
|
|
176
|
+
const provider = new Database(options);
|
|
177
|
+
await new Promise<void>((resolve, reject) => {
|
|
178
|
+
provider.once("ready", () => {
|
|
179
|
+
void (async () => {
|
|
180
|
+
try {
|
|
181
|
+
const oldA = await provider.createDevice(
|
|
182
|
+
userID,
|
|
183
|
+
devicePayload("old-a", "a".repeat(64)),
|
|
184
|
+
);
|
|
185
|
+
const oldB = await provider.createDevice(
|
|
186
|
+
userID,
|
|
187
|
+
devicePayload("old-b", "b".repeat(64)),
|
|
188
|
+
);
|
|
189
|
+
await provider.saveOTK(userID, oldA.deviceID, [
|
|
190
|
+
{
|
|
191
|
+
deviceID: oldA.deviceID,
|
|
192
|
+
index: 1,
|
|
193
|
+
publicKey,
|
|
194
|
+
signature,
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
await provider.saveNotificationSubscription({
|
|
198
|
+
channel: "expo",
|
|
199
|
+
deviceID: oldA.deviceID,
|
|
200
|
+
events: ["deviceRequest"],
|
|
201
|
+
platform: "ios",
|
|
202
|
+
token: "ExponentPushToken[old-a]",
|
|
203
|
+
userID,
|
|
204
|
+
});
|
|
205
|
+
await provider.saveNotificationSubscription({
|
|
206
|
+
channel: "expo",
|
|
207
|
+
deviceID: oldB.deviceID,
|
|
208
|
+
events: ["*"],
|
|
209
|
+
platform: "android",
|
|
210
|
+
token: "ExponentPushToken[old-b]",
|
|
211
|
+
userID,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const recovered = await provider.recoverDevice(
|
|
215
|
+
userID,
|
|
216
|
+
devicePayload("recovered", "c".repeat(64)),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(recovered.revokedDeviceIDs.sort()).toEqual(
|
|
220
|
+
[oldA.deviceID, oldB.deviceID].sort(),
|
|
221
|
+
);
|
|
222
|
+
expect(
|
|
223
|
+
await provider.retrieveDevice(oldA.deviceID),
|
|
224
|
+
).toBeNull();
|
|
225
|
+
expect(
|
|
226
|
+
await provider.retrieveDevice(oldB.deviceID),
|
|
227
|
+
).toBeNull();
|
|
228
|
+
expect(
|
|
229
|
+
await provider.retrieveDevice(
|
|
230
|
+
recovered.device.deviceID,
|
|
231
|
+
),
|
|
232
|
+
).toEqual(recovered.device);
|
|
233
|
+
expect(
|
|
234
|
+
await provider.retrieveUserDeviceList([userID]),
|
|
235
|
+
).toEqual([recovered.device]);
|
|
236
|
+
expect(
|
|
237
|
+
await provider.getPreKeys(oldA.deviceID),
|
|
238
|
+
).toBeNull();
|
|
239
|
+
expect(
|
|
240
|
+
await provider.getOTK(oldA.deviceID),
|
|
241
|
+
).toBeNull();
|
|
242
|
+
expect(
|
|
243
|
+
await provider.retrieveNotificationSubscriptions(
|
|
244
|
+
{
|
|
245
|
+
event: "deviceRequest",
|
|
246
|
+
userID,
|
|
247
|
+
},
|
|
248
|
+
),
|
|
249
|
+
).toEqual([]);
|
|
250
|
+
await provider.close();
|
|
251
|
+
resolve();
|
|
252
|
+
} catch (e: unknown) {
|
|
253
|
+
await provider.close().catch(() => {
|
|
254
|
+
/* ignore cleanup failure */
|
|
255
|
+
});
|
|
256
|
+
reject(
|
|
257
|
+
e instanceof Error ? e : new Error(String(e)),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
})();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
159
266
|
describe("retrieveMail", () => {
|
|
160
267
|
it("returns queued mail in send order for logged-out batch drains", async () => {
|
|
161
268
|
expect.assertions(1);
|
|
@@ -231,9 +338,80 @@ describe("Database", () => {
|
|
|
231
338
|
});
|
|
232
339
|
});
|
|
233
340
|
|
|
341
|
+
describe("saveMailBatch", () => {
|
|
342
|
+
it("stores multiple queued mail rows in one insert", async () => {
|
|
343
|
+
expect.assertions(1);
|
|
344
|
+
|
|
345
|
+
const provider = new Database(options);
|
|
346
|
+
await new Promise<void>((resolve, reject) => {
|
|
347
|
+
provider.once("ready", () => {
|
|
348
|
+
void (async () => {
|
|
349
|
+
try {
|
|
350
|
+
const mail = (
|
|
351
|
+
mailID: string,
|
|
352
|
+
nonce: number,
|
|
353
|
+
): MailWS => ({
|
|
354
|
+
authorID: userID,
|
|
355
|
+
cipher: new Uint8Array([1, nonce]),
|
|
356
|
+
extra: new Uint8Array([2, nonce]),
|
|
357
|
+
forward: false,
|
|
358
|
+
group: null,
|
|
359
|
+
mailID,
|
|
360
|
+
mailType: MailType.initial,
|
|
361
|
+
nonce: new Uint8Array([3, nonce]),
|
|
362
|
+
readerID: userID,
|
|
363
|
+
recipient: deviceID,
|
|
364
|
+
sender: "sender-a",
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await provider.saveMailBatch([
|
|
368
|
+
{
|
|
369
|
+
header: new Uint8Array([4, 1]),
|
|
370
|
+
mail: mail(
|
|
371
|
+
"00000000-0000-0000-0000-000000000011",
|
|
372
|
+
1,
|
|
373
|
+
),
|
|
374
|
+
senderDeviceID: "sender-a",
|
|
375
|
+
userID,
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
header: new Uint8Array([4, 2]),
|
|
379
|
+
mail: mail(
|
|
380
|
+
"00000000-0000-0000-0000-000000000012",
|
|
381
|
+
2,
|
|
382
|
+
),
|
|
383
|
+
senderDeviceID: "sender-a",
|
|
384
|
+
userID,
|
|
385
|
+
},
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
const result =
|
|
389
|
+
await provider.retrieveMail(deviceID);
|
|
390
|
+
expect(
|
|
391
|
+
result.map(
|
|
392
|
+
([, body]: [Uint8Array, MailWS, string]) =>
|
|
393
|
+
body.mailID,
|
|
394
|
+
),
|
|
395
|
+
).toEqual([
|
|
396
|
+
"00000000-0000-0000-0000-000000000011",
|
|
397
|
+
"00000000-0000-0000-0000-000000000012",
|
|
398
|
+
]);
|
|
399
|
+
await provider.close();
|
|
400
|
+
resolve();
|
|
401
|
+
} catch (e: unknown) {
|
|
402
|
+
reject(
|
|
403
|
+
e instanceof Error ? e : new Error(String(e)),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
})();
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
234
412
|
describe("notification subscriptions", () => {
|
|
235
413
|
it("upserts and filters Expo push subscriptions by event", async () => {
|
|
236
|
-
expect.assertions(
|
|
414
|
+
expect.assertions(8);
|
|
237
415
|
|
|
238
416
|
const provider = new Database(options);
|
|
239
417
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -297,6 +475,17 @@ describe("Database", () => {
|
|
|
297
475
|
);
|
|
298
476
|
expect(deviceSubsAfterUpdate).toHaveLength(1);
|
|
299
477
|
|
|
478
|
+
await provider.deleteDevice(deviceID);
|
|
479
|
+
expect(
|
|
480
|
+
await provider.retrieveNotificationSubscriptions(
|
|
481
|
+
{
|
|
482
|
+
deviceID,
|
|
483
|
+
event: "deviceRequest",
|
|
484
|
+
userID,
|
|
485
|
+
},
|
|
486
|
+
),
|
|
487
|
+
).toEqual([]);
|
|
488
|
+
|
|
300
489
|
await provider.close();
|
|
301
490
|
resolve();
|
|
302
491
|
} catch (e: unknown) {
|
|
@@ -0,0 +1,91 @@
|
|
|
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 { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
isSpireFipsEnabled,
|
|
11
|
+
normalizeEnvValue,
|
|
12
|
+
validateSpireRuntimeEnv,
|
|
13
|
+
} from "../utils/loadEnv.ts";
|
|
14
|
+
|
|
15
|
+
const TWEETNACL_SPK = "ab".repeat(64);
|
|
16
|
+
const FIPS_SPK = "cd".repeat(90);
|
|
17
|
+
const JWT_SECRET = "ef".repeat(32);
|
|
18
|
+
|
|
19
|
+
describe("normalizeEnvValue", () => {
|
|
20
|
+
it("strips matching quotes from compose/dotenv values", () => {
|
|
21
|
+
expect(normalizeEnvValue(`"${TWEETNACL_SPK}"`)).toBe(TWEETNACL_SPK);
|
|
22
|
+
expect(normalizeEnvValue(`'${JWT_SECRET}'`)).toBe(JWT_SECRET);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("trims whitespace without stripping unmatched quotes", () => {
|
|
26
|
+
expect(normalizeEnvValue(` ${TWEETNACL_SPK} `)).toBe(TWEETNACL_SPK);
|
|
27
|
+
expect(normalizeEnvValue(`"${TWEETNACL_SPK}`)).toBe(
|
|
28
|
+
`"${TWEETNACL_SPK}`,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isSpireFipsEnabled", () => {
|
|
34
|
+
it("accepts true and 1 only", () => {
|
|
35
|
+
expect(isSpireFipsEnabled("true")).toBe(true);
|
|
36
|
+
expect(isSpireFipsEnabled('"true"')).toBe(true);
|
|
37
|
+
expect(isSpireFipsEnabled("1")).toBe(true);
|
|
38
|
+
expect(isSpireFipsEnabled("false")).toBe(false);
|
|
39
|
+
expect(isSpireFipsEnabled(undefined)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("validateSpireRuntimeEnv", () => {
|
|
44
|
+
it("accepts quoted compose-safe tweetnacl keys", () => {
|
|
45
|
+
expect(() => {
|
|
46
|
+
validateSpireRuntimeEnv({
|
|
47
|
+
JWT_SECRET: `"${JWT_SECRET}"`,
|
|
48
|
+
SPIRE_FIPS: "false",
|
|
49
|
+
SPK: `"${TWEETNACL_SPK}"`,
|
|
50
|
+
});
|
|
51
|
+
}).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects non-hex SPK values before crypto init", () => {
|
|
55
|
+
expect(() => {
|
|
56
|
+
validateSpireRuntimeEnv({
|
|
57
|
+
JWT_SECRET,
|
|
58
|
+
SPK: `"${TWEETNACL_SPK}`,
|
|
59
|
+
});
|
|
60
|
+
}).toThrow(/SPK must be an even-length hex string/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects a FIPS flag with a tweetnacl-sized SPK", () => {
|
|
64
|
+
expect(() => {
|
|
65
|
+
validateSpireRuntimeEnv({
|
|
66
|
+
JWT_SECRET,
|
|
67
|
+
SPIRE_FIPS: "true",
|
|
68
|
+
SPK: TWEETNACL_SPK,
|
|
69
|
+
});
|
|
70
|
+
}).toThrow(/requires an SPK from .*gen-spk-fips/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects a FIPS-sized SPK without the FIPS flag", () => {
|
|
74
|
+
expect(() => {
|
|
75
|
+
validateSpireRuntimeEnv({
|
|
76
|
+
JWT_SECRET,
|
|
77
|
+
SPIRE_FIPS: "false",
|
|
78
|
+
SPK: FIPS_SPK,
|
|
79
|
+
});
|
|
80
|
+
}).toThrow(/SPK must be 128 hex characters/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects reusing SPK as JWT_SECRET", () => {
|
|
84
|
+
expect(() => {
|
|
85
|
+
validateSpireRuntimeEnv({
|
|
86
|
+
JWT_SECRET: TWEETNACL_SPK,
|
|
87
|
+
SPK: TWEETNACL_SPK,
|
|
88
|
+
});
|
|
89
|
+
}).toThrow(/JWT_SECRET must be separate from SPK/);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -9,6 +9,7 @@ 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";
|
|
12
13
|
|
|
13
14
|
vi.mock("uuid", () => ({
|
|
14
15
|
parse: (s: string) => {
|
|
@@ -191,6 +192,51 @@ describe("Database passkeys", () => {
|
|
|
191
192
|
});
|
|
192
193
|
});
|
|
193
194
|
|
|
195
|
+
describe("passkeySecondFactorError", () => {
|
|
196
|
+
it("allows bootstrap login when the account has no passkeys", async () => {
|
|
197
|
+
expect.assertions(1);
|
|
198
|
+
await withDb(async (db) => {
|
|
199
|
+
await expect(
|
|
200
|
+
passkeySecondFactorError(db, userID, undefined, "mismatch"),
|
|
201
|
+
).resolves.toBeNull();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("requires a matching passkey once the account has one", async () => {
|
|
206
|
+
expect.assertions(3);
|
|
207
|
+
await withDb(async (db) => {
|
|
208
|
+
const created = await db.createPasskey(
|
|
209
|
+
userID,
|
|
210
|
+
samplePasskey.name,
|
|
211
|
+
samplePasskey.credentialID,
|
|
212
|
+
samplePasskey.publicKeyHex,
|
|
213
|
+
samplePasskey.algorithm,
|
|
214
|
+
samplePasskey.transports,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await expect(
|
|
218
|
+
passkeySecondFactorError(db, userID, undefined, "mismatch"),
|
|
219
|
+
).resolves.toBe("Passkey verification required.");
|
|
220
|
+
await expect(
|
|
221
|
+
passkeySecondFactorError(
|
|
222
|
+
db,
|
|
223
|
+
userID,
|
|
224
|
+
"not-this-passkey",
|
|
225
|
+
"mismatch",
|
|
226
|
+
),
|
|
227
|
+
).resolves.toBe("mismatch");
|
|
228
|
+
await expect(
|
|
229
|
+
passkeySecondFactorError(
|
|
230
|
+
db,
|
|
231
|
+
userID,
|
|
232
|
+
created.passkeyID,
|
|
233
|
+
"mismatch",
|
|
234
|
+
),
|
|
235
|
+
).resolves.toBeNull();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
194
240
|
describe("deletePasskey", () => {
|
|
195
241
|
it("removes the passkey row", async () => {
|
|
196
242
|
expect.assertions(2);
|
|
@@ -0,0 +1,60 @@
|
|
|
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 { afterEach, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
DEV_API_KEY_HEADER,
|
|
11
|
+
devApiKeyMatches,
|
|
12
|
+
devApiKeySkipsRateLimits,
|
|
13
|
+
} from "../server/rateLimit.ts";
|
|
14
|
+
|
|
15
|
+
const ORIGINAL_DEV_API_KEY = process.env["DEV_API_KEY"];
|
|
16
|
+
const ORIGINAL_DISABLE_RATE_LIMITS = process.env["SPIRE_DISABLE_RATE_LIMITS"];
|
|
17
|
+
|
|
18
|
+
function requestWithDevKey(value: string | undefined) {
|
|
19
|
+
return {
|
|
20
|
+
get(name: string): string | undefined {
|
|
21
|
+
return name.toLowerCase() === DEV_API_KEY_HEADER
|
|
22
|
+
? value
|
|
23
|
+
: undefined;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("dev API key matching", () => {
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (ORIGINAL_DEV_API_KEY === undefined) {
|
|
31
|
+
delete process.env["DEV_API_KEY"];
|
|
32
|
+
} else {
|
|
33
|
+
process.env["DEV_API_KEY"] = ORIGINAL_DEV_API_KEY;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (ORIGINAL_DISABLE_RATE_LIMITS === undefined) {
|
|
37
|
+
delete process.env["SPIRE_DISABLE_RATE_LIMITS"];
|
|
38
|
+
} else {
|
|
39
|
+
process.env["SPIRE_DISABLE_RATE_LIMITS"] =
|
|
40
|
+
ORIGINAL_DISABLE_RATE_LIMITS;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("matches the configured dev API key exactly", () => {
|
|
45
|
+
process.env["DEV_API_KEY"] = "test-secret";
|
|
46
|
+
|
|
47
|
+
expect(devApiKeyMatches(requestWithDevKey("test-secret"))).toBe(true);
|
|
48
|
+
expect(devApiKeyMatches(requestWithDevKey("wrong-secret"))).toBe(false);
|
|
49
|
+
expect(devApiKeyMatches(requestWithDevKey(undefined))).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not treat disabled rate limits as a dev API key match", () => {
|
|
53
|
+
delete process.env["DEV_API_KEY"];
|
|
54
|
+
process.env["SPIRE_DISABLE_RATE_LIMITS"] = "1";
|
|
55
|
+
|
|
56
|
+
const req = requestWithDevKey(undefined);
|
|
57
|
+
expect(devApiKeyMatches(req)).toBe(false);
|
|
58
|
+
expect(devApiKeySkipsRateLimits(req)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -13,8 +13,8 @@ export async function down(db: Kysely<unknown>): Promise<void> {
|
|
|
13
13
|
// Passkeys are an administrative second-class credential alongside
|
|
14
14
|
// `devices`. They cannot send/decrypt mail (no ratchet keys), but they
|
|
15
15
|
// can authenticate the owning user, list devices, delete devices, and
|
|
16
|
-
//
|
|
17
|
-
// recovery
|
|
16
|
+
// recover/reject pending device-enrollment requests — i.e. account
|
|
17
|
+
// recovery without additive passkey-only provisioning.
|
|
18
18
|
//
|
|
19
19
|
// `credentialID` is the WebAuthn credential id (base64url, opaque), and
|
|
20
20
|
// is unique across all passkeys. `publicKey` is the COSE_Key bytes
|
package/src/server/index.ts
CHANGED
|
@@ -42,7 +42,11 @@ import {
|
|
|
42
42
|
hasPermission,
|
|
43
43
|
userHasPermission,
|
|
44
44
|
} from "./permissions.ts";
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
devApiKeyMatches,
|
|
47
|
+
globalLimiter,
|
|
48
|
+
keyBundleLimiter,
|
|
49
|
+
} from "./rateLimit.ts";
|
|
46
50
|
import { getUserRouter } from "./user.ts";
|
|
47
51
|
import { censorUser, getParam, getUser } from "./utils.ts";
|
|
48
52
|
import { getWellKnownRouter } from "./wellKnown.ts";
|
|
@@ -200,7 +204,7 @@ export const protect: express.RequestHandler = (req, res, next) => {
|
|
|
200
204
|
* Restrict a route to passkey-scoped JWTs (used by the parallel
|
|
201
205
|
* `/user/:id/passkey/devices/...` admin/recovery routes). A
|
|
202
206
|
* passkey-authenticated caller can list devices, delete devices, and
|
|
203
|
-
*
|
|
207
|
+
* recover or reject pending enrollments — and nothing else.
|
|
204
208
|
*/
|
|
205
209
|
export const protectPasskey: express.RequestHandler = (req, res, next) => {
|
|
206
210
|
if (!req.user || !req.passkey) {
|
|
@@ -243,6 +247,7 @@ export const initApp = (
|
|
|
243
247
|
data?: unknown,
|
|
244
248
|
deviceID?: string,
|
|
245
249
|
) => void,
|
|
250
|
+
disconnectDevices?: (deviceIDs: string[]) => void,
|
|
246
251
|
) => {
|
|
247
252
|
// INIT ROUTERS
|
|
248
253
|
const userRouter = getUserRouter(db, tokenValidator, notify);
|
|
@@ -250,7 +255,11 @@ export const initApp = (
|
|
|
250
255
|
const avatarRouter = getAvatarRouter();
|
|
251
256
|
const inviteRouter = getInviteRouter(db, tokenValidator, notify);
|
|
252
257
|
const passkeyRouter = getPasskeyRouter(db);
|
|
253
|
-
const passkeyDeviceRouter = getPasskeyDeviceRouter(
|
|
258
|
+
const passkeyDeviceRouter = getPasskeyDeviceRouter(
|
|
259
|
+
db,
|
|
260
|
+
notify,
|
|
261
|
+
disconnectDevices,
|
|
262
|
+
);
|
|
254
263
|
|
|
255
264
|
// MIDDLEWARE
|
|
256
265
|
|
|
@@ -663,6 +672,20 @@ export const initApp = (
|
|
|
663
672
|
res.sendStatus(404);
|
|
664
673
|
return;
|
|
665
674
|
}
|
|
675
|
+
if (req.user?.userID !== device.owner) {
|
|
676
|
+
res.sendStatus(401);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const passkeys = await db.retrievePasskeysByUser(device.owner);
|
|
680
|
+
// The CLI stress harness cannot perform a WebAuthn ceremony.
|
|
681
|
+
// Keep normal clients gated, but let the existing dev/load-test key
|
|
682
|
+
// exercise device connect on disposable accounts.
|
|
683
|
+
if (passkeys.length === 0 && !devApiKeyMatches(req)) {
|
|
684
|
+
res.status(403).send({
|
|
685
|
+
error: "A passkey must be registered before this device can connect.",
|
|
686
|
+
});
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
666
689
|
|
|
667
690
|
const regKey = await spireXSignOpenAsync(
|
|
668
691
|
signed,
|
package/src/server/passkey.ts
CHANGED
|
@@ -192,17 +192,6 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
192
192
|
res.sendStatus(401);
|
|
193
193
|
return;
|
|
194
194
|
}
|
|
195
|
-
// Passwords/passkey JWTs both come through `protect`; only
|
|
196
|
-
// a real device session may add a passkey to keep the
|
|
197
|
-
// recovery story symmetric with device delete (a passkey
|
|
198
|
-
// can't bootstrap another passkey).
|
|
199
|
-
if (!req.device) {
|
|
200
|
-
res.status(401).send({
|
|
201
|
-
error: "Adding a passkey requires an authenticated device.",
|
|
202
|
-
});
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
195
|
const parsed = PasskeyRegistrationStartPayloadSchema.safeParse(
|
|
207
196
|
req.body,
|
|
208
197
|
);
|
|
@@ -215,6 +204,16 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
215
204
|
}
|
|
216
205
|
|
|
217
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.
|
|
211
|
+
if (!req.device && existing.length > 0) {
|
|
212
|
+
res.status(401).send({
|
|
213
|
+
error: "Adding another passkey requires an authenticated device.",
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
218
217
|
if (existing.length >= MAX_PASSKEYS_PER_USER) {
|
|
219
218
|
res.status(409).send({
|
|
220
219
|
error: `Each account is limited to ${MAX_PASSKEYS_PER_USER} passkeys.`,
|
|
@@ -277,12 +276,6 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
277
276
|
res.sendStatus(401);
|
|
278
277
|
return;
|
|
279
278
|
}
|
|
280
|
-
if (!req.device) {
|
|
281
|
-
res.status(401).send({
|
|
282
|
-
error: "Adding a passkey requires an authenticated device.",
|
|
283
|
-
});
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
279
|
|
|
287
280
|
const parsed = PasskeyRegistrationFinishPayloadSchema.safeParse(
|
|
288
281
|
req.body,
|
|
@@ -295,6 +288,14 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
295
288
|
return;
|
|
296
289
|
}
|
|
297
290
|
|
|
291
|
+
const existing = await db.retrievePasskeysByUser(userID);
|
|
292
|
+
if (!req.device && existing.length > 0) {
|
|
293
|
+
res.status(401).send({
|
|
294
|
+
error: "Adding another passkey requires an authenticated device.",
|
|
295
|
+
});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
298
299
|
pruneRegistrations();
|
|
299
300
|
const pending = pendingRegistrations.get(parsed.data.requestID);
|
|
300
301
|
if (!pending || pending.userID !== userID) {
|
|
@@ -409,6 +410,13 @@ export const getPasskeyRouter = (db: Database) => {
|
|
|
409
410
|
res.sendStatus(404);
|
|
410
411
|
return;
|
|
411
412
|
}
|
|
413
|
+
const passkeys = await db.retrievePasskeysByUser(userID);
|
|
414
|
+
if (passkeys.length <= 1) {
|
|
415
|
+
res.status(400).send({
|
|
416
|
+
error: "You can't delete your last passkey.",
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
412
420
|
await db.deletePasskey(passkeyID);
|
|
413
421
|
res.sendStatus(200);
|
|
414
422
|
},
|
|
@@ -10,7 +10,10 @@ import express from "express";
|
|
|
10
10
|
|
|
11
11
|
import { msgpack } from "../utils/msgpack.ts";
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
recoverDeviceEnrollmentRequest,
|
|
15
|
+
resolveDeviceEnrollmentRequest,
|
|
16
|
+
} from "./user.ts";
|
|
14
17
|
import { getParam, getUser } from "./utils.ts";
|
|
15
18
|
|
|
16
19
|
import { protectPasskey } from "./index.ts";
|
|
@@ -21,8 +24,8 @@ import { protectPasskey } from "./index.ts";
|
|
|
21
24
|
*
|
|
22
25
|
* - `GET /user/:id/passkey/devices` — list
|
|
23
26
|
* - `DELETE /user/:id/passkey/devices/:deviceID` — remove
|
|
24
|
-
* - `POST /user/:id/passkey/devices/requests/:requestID/approve` — approve
|
|
25
27
|
* - `POST /user/:id/passkey/devices/requests/:requestID/reject` — reject
|
|
28
|
+
* - `POST /user/:id/passkey/recover/devices/requests/:requestID` — recover
|
|
26
29
|
*
|
|
27
30
|
* The route family is parallel to `/user/:id/devices/...` so the
|
|
28
31
|
* existing device-authenticated flow stays untouched (and there's no
|
|
@@ -42,6 +45,7 @@ export const getPasskeyDeviceRouter = (
|
|
|
42
45
|
data?: unknown,
|
|
43
46
|
deviceID?: string,
|
|
44
47
|
) => void,
|
|
48
|
+
disconnectDevices?: (deviceIDs: string[]) => void,
|
|
45
49
|
) => {
|
|
46
50
|
const router = express.Router();
|
|
47
51
|
|
|
@@ -79,10 +83,9 @@ export const getPasskeyDeviceRouter = (
|
|
|
79
83
|
// The device-auth `DELETE /user/:id/devices/:deviceID`
|
|
80
84
|
// refuses to delete the user's last device (a device
|
|
81
85
|
// can't lock itself out). Passkeys may delete the last
|
|
82
|
-
// device on purpose: that's the
|
|
86
|
+
// device on purpose: that's part of the recovery story —
|
|
83
87
|
// "I lost my phone, sign in with the passkey, wipe the
|
|
84
|
-
// old device, then
|
|
85
|
-
// standing in as the approver."
|
|
88
|
+
// old device, then recover onto a new one."
|
|
86
89
|
await db.deleteDevice(deviceID);
|
|
87
90
|
// Tell whoever's online that the device-list shape
|
|
88
91
|
// changed; clients use this to refresh the Settings →
|
|
@@ -93,7 +96,7 @@ export const getPasskeyDeviceRouter = (
|
|
|
93
96
|
);
|
|
94
97
|
|
|
95
98
|
router.post(
|
|
96
|
-
"/user/:id/passkey/devices/requests/:requestID
|
|
99
|
+
"/user/:id/passkey/recover/devices/requests/:requestID",
|
|
97
100
|
protectPasskey,
|
|
98
101
|
async (req, res) => {
|
|
99
102
|
const userDetails = getUser(req);
|
|
@@ -103,20 +106,20 @@ export const getPasskeyDeviceRouter = (
|
|
|
103
106
|
res.sendStatus(401);
|
|
104
107
|
return;
|
|
105
108
|
}
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
const result = await resolveDeviceEnrollmentRequest({
|
|
113
|
-
action: "approve",
|
|
109
|
+
// Recovery is intentionally the only passkey-backed
|
|
110
|
+
// provisioning path: it provisions the pending device and
|
|
111
|
+
// revokes every previously-active device for the account in
|
|
112
|
+
// one server-side operation. Clients cannot accidentally
|
|
113
|
+
// restore an account while leaving lost devices trusted.
|
|
114
|
+
const result = await recoverDeviceEnrollmentRequest({
|
|
114
115
|
db,
|
|
115
116
|
notify,
|
|
116
117
|
requestID,
|
|
117
118
|
userID,
|
|
118
119
|
});
|
|
119
120
|
if (result.kind === "ok") {
|
|
121
|
+
notify(userID, "deviceListChanged", crypto.randomUUID());
|
|
122
|
+
disconnectDevices?.(result.revokedDeviceIDs);
|
|
120
123
|
res.send(msgpack.encode(result.device));
|
|
121
124
|
return;
|
|
122
125
|
}
|