@vex-chat/spire 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Database.d.ts +8 -2
- package/dist/Database.js +64 -6
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.js +3 -4
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.d.ts +1 -2
- package/dist/Spire.js +3 -15
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +10 -0
- package/dist/migrations/2026-05-25_device-passkey-approvals.d.ts +8 -0
- package/dist/migrations/2026-05-25_device-passkey-approvals.js +26 -0
- package/dist/migrations/2026-05-25_device-passkey-approvals.js.map +1 -0
- package/dist/server/passkeyDevices.js +6 -0
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/passkeySecondFactor.d.ts +9 -0
- package/dist/server/passkeySecondFactor.js +24 -0
- package/dist/server/passkeySecondFactor.js.map +1 -0
- package/dist/server/user.d.ts +1 -0
- package/dist/server/user.js +15 -2
- package/dist/server/user.js.map +1 -1
- package/dist/types/express.d.ts +1 -1
- package/package.json +2 -2
- package/src/Database.ts +86 -5
- package/src/NotificationService.ts +3 -4
- package/src/Spire.ts +4 -20
- package/src/__tests__/Database.spec.ts +8 -1
- package/src/__tests__/notifyFanout.spec.ts +44 -0
- package/src/__tests__/passkeys.spec.ts +73 -0
- package/src/db/schema.ts +12 -0
- package/src/migrations/2026-05-25_device-passkey-approvals.ts +30 -0
- package/src/server/passkeyDevices.ts +6 -0
- package/src/server/passkeySecondFactor.ts +35 -0
- package/src/server/user.ts +22 -0
- package/src/types/express.ts +1 -1
package/src/Database.ts
CHANGED
|
@@ -152,6 +152,11 @@ export interface SaveNotificationSubscriptionInput {
|
|
|
152
152
|
userID: string;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
interface DevicePasskeyApprovalInput {
|
|
156
|
+
approvedByDeviceID?: null | string;
|
|
157
|
+
approvedByPasskeyID: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
155
160
|
export class Database extends EventEmitter {
|
|
156
161
|
private static readonly EMOJI_LIST_CACHE_TTL_MS = 10_000;
|
|
157
162
|
|
|
@@ -226,18 +231,18 @@ export class Database extends EventEmitter {
|
|
|
226
231
|
public async createDevice(
|
|
227
232
|
owner: string,
|
|
228
233
|
payload: DevicePayload,
|
|
234
|
+
passkeyApproval?: DevicePasskeyApprovalInput,
|
|
229
235
|
): Promise<Device> {
|
|
236
|
+
const now = new Date().toISOString();
|
|
230
237
|
const device = {
|
|
231
238
|
deleted: 0,
|
|
232
239
|
deviceID: crypto.randomUUID(),
|
|
233
|
-
lastLogin:
|
|
240
|
+
lastLogin: now,
|
|
234
241
|
name: payload.deviceName,
|
|
235
242
|
owner,
|
|
236
243
|
signKey: payload.signKey,
|
|
237
244
|
};
|
|
238
245
|
|
|
239
|
-
await this.db.insertInto("devices").values(device).execute();
|
|
240
|
-
|
|
241
246
|
const medPreKeys = {
|
|
242
247
|
deviceID: device.deviceID,
|
|
243
248
|
index: payload.preKeyIndex,
|
|
@@ -247,7 +252,34 @@ export class Database extends EventEmitter {
|
|
|
247
252
|
userID: owner,
|
|
248
253
|
};
|
|
249
254
|
|
|
250
|
-
await this.db.
|
|
255
|
+
await this.db.transaction().execute(async (trx) => {
|
|
256
|
+
await trx.insertInto("devices").values(device).execute();
|
|
257
|
+
await trx.insertInto("preKeys").values(medPreKeys).execute();
|
|
258
|
+
if (passkeyApproval) {
|
|
259
|
+
await trx
|
|
260
|
+
.insertInto("device_passkey_approvals")
|
|
261
|
+
.values({
|
|
262
|
+
approvedAt: now,
|
|
263
|
+
approvedByDeviceID:
|
|
264
|
+
passkeyApproval.approvedByDeviceID ?? null,
|
|
265
|
+
approvedByPasskeyID:
|
|
266
|
+
passkeyApproval.approvedByPasskeyID,
|
|
267
|
+
deviceID: device.deviceID,
|
|
268
|
+
userID: owner,
|
|
269
|
+
})
|
|
270
|
+
.onConflict((oc) =>
|
|
271
|
+
oc.column("deviceID").doUpdateSet({
|
|
272
|
+
approvedAt: now,
|
|
273
|
+
approvedByDeviceID:
|
|
274
|
+
passkeyApproval.approvedByDeviceID ?? null,
|
|
275
|
+
approvedByPasskeyID:
|
|
276
|
+
passkeyApproval.approvedByPasskeyID,
|
|
277
|
+
userID: owner,
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
280
|
+
.execute();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
251
283
|
|
|
252
284
|
return toDevice(device);
|
|
253
285
|
}
|
|
@@ -446,6 +478,11 @@ export class Database extends EventEmitter {
|
|
|
446
478
|
.where("deviceID", "=", deviceID)
|
|
447
479
|
.execute();
|
|
448
480
|
|
|
481
|
+
await this.db
|
|
482
|
+
.deleteFrom("device_passkey_approvals")
|
|
483
|
+
.where("deviceID", "=", deviceID)
|
|
484
|
+
.execute();
|
|
485
|
+
|
|
449
486
|
await this.db
|
|
450
487
|
.updateTable("devices")
|
|
451
488
|
.set({ deleted: 1 })
|
|
@@ -697,6 +734,20 @@ export class Database extends EventEmitter {
|
|
|
697
734
|
.execute();
|
|
698
735
|
}
|
|
699
736
|
|
|
737
|
+
public async isDevicePasskeyApproved(
|
|
738
|
+
userID: string,
|
|
739
|
+
deviceID: string,
|
|
740
|
+
): Promise<boolean> {
|
|
741
|
+
const row = await this.db
|
|
742
|
+
.selectFrom("device_passkey_approvals")
|
|
743
|
+
.select("deviceID")
|
|
744
|
+
.where("userID", "=", userID)
|
|
745
|
+
.where("deviceID", "=", deviceID)
|
|
746
|
+
.limit(1)
|
|
747
|
+
.executeTakeFirst();
|
|
748
|
+
return row !== undefined;
|
|
749
|
+
}
|
|
750
|
+
|
|
700
751
|
public async isHealthy(): Promise<boolean> {
|
|
701
752
|
try {
|
|
702
753
|
await sql`select 1 as ok`.execute(this.db);
|
|
@@ -755,11 +806,13 @@ export class Database extends EventEmitter {
|
|
|
755
806
|
public async recoverDevice(
|
|
756
807
|
owner: string,
|
|
757
808
|
payload: DevicePayload,
|
|
809
|
+
passkeyApproval?: DevicePasskeyApprovalInput,
|
|
758
810
|
): Promise<{ device: Device; revokedDeviceIDs: string[] }> {
|
|
811
|
+
const now = new Date().toISOString();
|
|
759
812
|
const device = {
|
|
760
813
|
deleted: 0,
|
|
761
814
|
deviceID: crypto.randomUUID(),
|
|
762
|
-
lastLogin:
|
|
815
|
+
lastLogin: now,
|
|
763
816
|
name: payload.deviceName,
|
|
764
817
|
owner,
|
|
765
818
|
signKey: payload.signKey,
|
|
@@ -784,6 +837,30 @@ export class Database extends EventEmitter {
|
|
|
784
837
|
|
|
785
838
|
await trx.insertInto("devices").values(device).execute();
|
|
786
839
|
await trx.insertInto("preKeys").values(medPreKeys).execute();
|
|
840
|
+
if (passkeyApproval) {
|
|
841
|
+
await trx
|
|
842
|
+
.insertInto("device_passkey_approvals")
|
|
843
|
+
.values({
|
|
844
|
+
approvedAt: now,
|
|
845
|
+
approvedByDeviceID:
|
|
846
|
+
passkeyApproval.approvedByDeviceID ?? null,
|
|
847
|
+
approvedByPasskeyID:
|
|
848
|
+
passkeyApproval.approvedByPasskeyID,
|
|
849
|
+
deviceID: device.deviceID,
|
|
850
|
+
userID: owner,
|
|
851
|
+
})
|
|
852
|
+
.onConflict((oc) =>
|
|
853
|
+
oc.column("deviceID").doUpdateSet({
|
|
854
|
+
approvedAt: now,
|
|
855
|
+
approvedByDeviceID:
|
|
856
|
+
passkeyApproval.approvedByDeviceID ?? null,
|
|
857
|
+
approvedByPasskeyID:
|
|
858
|
+
passkeyApproval.approvedByPasskeyID,
|
|
859
|
+
userID: owner,
|
|
860
|
+
}),
|
|
861
|
+
)
|
|
862
|
+
.execute();
|
|
863
|
+
}
|
|
787
864
|
|
|
788
865
|
if (revokedDeviceIDs.length > 0) {
|
|
789
866
|
await trx
|
|
@@ -798,6 +875,10 @@ export class Database extends EventEmitter {
|
|
|
798
875
|
.deleteFrom("notification_subscriptions")
|
|
799
876
|
.where("deviceID", "in", revokedDeviceIDs)
|
|
800
877
|
.execute();
|
|
878
|
+
await trx
|
|
879
|
+
.deleteFrom("device_passkey_approvals")
|
|
880
|
+
.where("deviceID", "in", revokedDeviceIDs)
|
|
881
|
+
.execute();
|
|
801
882
|
await trx
|
|
802
883
|
.updateTable("devices")
|
|
803
884
|
.set({ deleted: 1 })
|
|
@@ -474,13 +474,12 @@ function expoMessageForSubscription(
|
|
|
474
474
|
: undefined,
|
|
475
475
|
data,
|
|
476
476
|
priority: subscription.platform === "android" ? "high" : undefined,
|
|
477
|
-
// Do not set `sound: "default"` here. Current mobile native
|
|
478
|
-
// notification modules can treat it as a custom sound resource named
|
|
479
|
-
// "default" instead of the platform default, which causes warnings when
|
|
480
|
-
// no such bundled resource exists.
|
|
481
477
|
title,
|
|
482
478
|
to: subscription.token,
|
|
483
479
|
};
|
|
480
|
+
if (subscription.platform === "ios") {
|
|
481
|
+
message["sound"] = "default";
|
|
482
|
+
}
|
|
484
483
|
if (body) {
|
|
485
484
|
message["body"] = body;
|
|
486
485
|
}
|
package/src/Spire.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
MailIngressValidationError,
|
|
52
52
|
validateMailIngress,
|
|
53
53
|
} from "./server/mailIngress.ts";
|
|
54
|
+
import { passkeySecondFactorError } from "./server/passkeySecondFactor.ts";
|
|
54
55
|
import { authLimiter, devApiKeySkipsRateLimits } from "./server/rateLimit.ts";
|
|
55
56
|
import { createPendingDeviceEnrollmentRequest } from "./server/user.ts";
|
|
56
57
|
import { censorUser, getParam, getUser } from "./server/utils.ts";
|
|
@@ -59,6 +60,8 @@ import { getJwtSecret } from "./utils/jwtSecret.ts";
|
|
|
59
60
|
import { msgpack } from "./utils/msgpack.ts";
|
|
60
61
|
import { spireXSignOpenAsync } from "./utils/spireXSignOpenAsync.ts";
|
|
61
62
|
|
|
63
|
+
export { passkeySecondFactorError } from "./server/passkeySecondFactor.ts";
|
|
64
|
+
|
|
62
65
|
// expiry of regkeys = 24hr
|
|
63
66
|
export const TOKEN_EXPIRY = 1000 * 60 * 10;
|
|
64
67
|
export const JWT_EXPIRY = "7d";
|
|
@@ -127,26 +130,6 @@ interface ValidatedMailBatchEntry {
|
|
|
127
130
|
recipientDevice: Device;
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
export async function passkeySecondFactorError(
|
|
131
|
-
db: Database,
|
|
132
|
-
userID: string,
|
|
133
|
-
passkeyID: string | undefined,
|
|
134
|
-
mismatchError: string,
|
|
135
|
-
): Promise<null | string> {
|
|
136
|
-
const passkeys = await db.retrievePasskeysByUser(userID);
|
|
137
|
-
if (passkeys.length === 0) {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
if (!passkeyID) {
|
|
141
|
-
return "Passkey verification required.";
|
|
142
|
-
}
|
|
143
|
-
const passkey = await db.retrievePasskeyInternal(passkeyID);
|
|
144
|
-
if (!passkey || passkey.userID !== userID) {
|
|
145
|
-
return mismatchError;
|
|
146
|
-
}
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
133
|
const notificationSubscribePayload = z.object({
|
|
151
134
|
channel: z.literal("expo"),
|
|
152
135
|
events: z.array(z.string().min(1)).default(["mail"]),
|
|
@@ -813,6 +796,7 @@ export class Spire extends EventEmitter {
|
|
|
813
796
|
user.userID,
|
|
814
797
|
req.passkey?.passkeyID,
|
|
815
798
|
"Passkey verification does not match this device.",
|
|
799
|
+
{ trustedDeviceID: device.deviceID },
|
|
816
800
|
);
|
|
817
801
|
if (passkeyError) {
|
|
818
802
|
const body: { error: string; username?: string } = {
|
|
@@ -171,7 +171,7 @@ describe("Database", () => {
|
|
|
171
171
|
|
|
172
172
|
describe("recoverDevice", () => {
|
|
173
173
|
it("creates a new device and revokes all previous devices for the user", async () => {
|
|
174
|
-
expect.assertions(
|
|
174
|
+
expect.assertions(9);
|
|
175
175
|
|
|
176
176
|
const provider = new Database(options);
|
|
177
177
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -181,6 +181,7 @@ describe("Database", () => {
|
|
|
181
181
|
const oldA = await provider.createDevice(
|
|
182
182
|
userID,
|
|
183
183
|
devicePayload("old-a", "a".repeat(64)),
|
|
184
|
+
{ approvedByPasskeyID: "old-passkey" },
|
|
184
185
|
);
|
|
185
186
|
const oldB = await provider.createDevice(
|
|
186
187
|
userID,
|
|
@@ -247,6 +248,12 @@ describe("Database", () => {
|
|
|
247
248
|
},
|
|
248
249
|
),
|
|
249
250
|
).toEqual([]);
|
|
251
|
+
expect(
|
|
252
|
+
await provider.isDevicePasskeyApproved(
|
|
253
|
+
userID,
|
|
254
|
+
oldA.deviceID,
|
|
255
|
+
),
|
|
256
|
+
).toBe(false);
|
|
250
257
|
await provider.close();
|
|
251
258
|
resolve();
|
|
252
259
|
} catch (e: unknown) {
|
|
@@ -517,12 +517,14 @@ describe("Spire notify fanout", () => {
|
|
|
517
517
|
collapseId?: string;
|
|
518
518
|
data?: Record<string, unknown>;
|
|
519
519
|
priority?: string;
|
|
520
|
+
sound?: string;
|
|
520
521
|
tag?: string;
|
|
521
522
|
title?: string;
|
|
522
523
|
}>;
|
|
523
524
|
expect(messages[0]?.collapseId).toBe("vex-message-summary");
|
|
524
525
|
expect(messages[0]?.channelId).toBe("vex-push-messages-v2");
|
|
525
526
|
expect(messages[0]?.priority).toBe("high");
|
|
527
|
+
expect(messages[0]).not.toHaveProperty("sound");
|
|
526
528
|
expect(messages[0]?.tag).toBe("vex-message-summary");
|
|
527
529
|
expect(messages[0]?.title).toBe("New Message");
|
|
528
530
|
expect(messages[0]).not.toHaveProperty("body");
|
|
@@ -533,6 +535,48 @@ describe("Spire notify fanout", () => {
|
|
|
533
535
|
});
|
|
534
536
|
});
|
|
535
537
|
|
|
538
|
+
it("requests the default sound for iOS visible Expo pushes only", async () => {
|
|
539
|
+
const iosSubscription: NotificationSubscription = {
|
|
540
|
+
...subscription,
|
|
541
|
+
platform: "ios",
|
|
542
|
+
subscriptionID: "sub-ios",
|
|
543
|
+
};
|
|
544
|
+
const fetchMock = vi.fn().mockResolvedValueOnce({
|
|
545
|
+
json: () =>
|
|
546
|
+
Promise.resolve({
|
|
547
|
+
data: [{ id: "receipt-a", status: "ok" }],
|
|
548
|
+
}),
|
|
549
|
+
ok: true,
|
|
550
|
+
});
|
|
551
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
552
|
+
|
|
553
|
+
const { db } = createSpireHarness([], [iosSubscription]);
|
|
554
|
+
const service = new NotificationService(db, [], () => {});
|
|
555
|
+
|
|
556
|
+
await service["notifyPush"]({
|
|
557
|
+
deviceID: iosSubscription.deviceID,
|
|
558
|
+
event: "mail",
|
|
559
|
+
transmissionID: "00000000-0000-0000-0000-000000000016",
|
|
560
|
+
userID: iosSubscription.userID,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const init = fetchMock.mock.calls[0]?.[1] as
|
|
564
|
+
| undefined
|
|
565
|
+
| { body?: unknown };
|
|
566
|
+
const messages = JSON.parse(String(init?.body)) as Array<{
|
|
567
|
+
channelId?: string;
|
|
568
|
+
priority?: string;
|
|
569
|
+
sound?: string;
|
|
570
|
+
tag?: string;
|
|
571
|
+
title?: string;
|
|
572
|
+
}>;
|
|
573
|
+
expect(messages[0]?.sound).toBe("default");
|
|
574
|
+
expect(messages[0]).not.toHaveProperty("channelId");
|
|
575
|
+
expect(messages[0]).not.toHaveProperty("priority");
|
|
576
|
+
expect(messages[0]).not.toHaveProperty("tag");
|
|
577
|
+
expect(messages[0]?.title).toBe("New Message");
|
|
578
|
+
});
|
|
579
|
+
|
|
536
580
|
it("awaits ticket error cleanup so rejection stays on notifyPush", async () => {
|
|
537
581
|
const cleanupError = new Error("database unavailable");
|
|
538
582
|
const removeNotificationSubscription = vi.fn(() =>
|
|
@@ -42,6 +42,17 @@ const samplePasskey = {
|
|
|
42
42
|
transports: ["usb", "nfc"],
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
const sampleDevicePayload = {
|
|
46
|
+
deviceName: "iPhone",
|
|
47
|
+
preKey: "30c2d0294c1cfdbb73c6b3bbe6010088c2dba8384b04ff2e2b92172431d66b5e",
|
|
48
|
+
preKeyIndex: 1,
|
|
49
|
+
preKeySignature:
|
|
50
|
+
"dd0665079426c3efcf4dce9b1487e4aca132f8147581b3294c3f23ddd2b4ba8240a10082bd06805d7eb320d91af971da3306e11b60073ccc3d829710f5036004000030c2d0294c1cfdbb73c6b3bbe6010088c2dba8384b04ff2e2b92172431d66b5e",
|
|
51
|
+
signed: "00",
|
|
52
|
+
signKey: "a".repeat(64),
|
|
53
|
+
username: "alice",
|
|
54
|
+
};
|
|
55
|
+
|
|
45
56
|
async function withDb<T>(fn: (db: Database) => Promise<T>): Promise<T> {
|
|
46
57
|
const provider = new Database(options);
|
|
47
58
|
return new Promise<T>((resolve, reject) => {
|
|
@@ -235,6 +246,68 @@ describe("Database passkeys", () => {
|
|
|
235
246
|
).resolves.toBeNull();
|
|
236
247
|
});
|
|
237
248
|
});
|
|
249
|
+
|
|
250
|
+
it("allows device-key auth for devices provisioned under passkey approval", async () => {
|
|
251
|
+
expect.assertions(3);
|
|
252
|
+
await withDb(async (db) => {
|
|
253
|
+
const created = await db.createPasskey(
|
|
254
|
+
userID,
|
|
255
|
+
samplePasskey.name,
|
|
256
|
+
samplePasskey.credentialID,
|
|
257
|
+
samplePasskey.publicKeyHex,
|
|
258
|
+
samplePasskey.algorithm,
|
|
259
|
+
samplePasskey.transports,
|
|
260
|
+
);
|
|
261
|
+
const device = await db.createDevice(
|
|
262
|
+
userID,
|
|
263
|
+
sampleDevicePayload,
|
|
264
|
+
{ approvedByPasskeyID: created.passkeyID },
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
passkeySecondFactorError(db, userID, undefined, "mismatch"),
|
|
269
|
+
).resolves.toBe("Passkey verification required.");
|
|
270
|
+
await expect(
|
|
271
|
+
db.isDevicePasskeyApproved(userID, device.deviceID),
|
|
272
|
+
).resolves.toBe(true);
|
|
273
|
+
await expect(
|
|
274
|
+
passkeySecondFactorError(
|
|
275
|
+
db,
|
|
276
|
+
userID,
|
|
277
|
+
undefined,
|
|
278
|
+
"mismatch",
|
|
279
|
+
{ trustedDeviceID: device.deviceID },
|
|
280
|
+
),
|
|
281
|
+
).resolves.toBeNull();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("clears passkey approval trust when a device is deleted", async () => {
|
|
286
|
+
expect.assertions(2);
|
|
287
|
+
await withDb(async (db) => {
|
|
288
|
+
const created = await db.createPasskey(
|
|
289
|
+
userID,
|
|
290
|
+
samplePasskey.name,
|
|
291
|
+
samplePasskey.credentialID,
|
|
292
|
+
samplePasskey.publicKeyHex,
|
|
293
|
+
samplePasskey.algorithm,
|
|
294
|
+
samplePasskey.transports,
|
|
295
|
+
);
|
|
296
|
+
const device = await db.createDevice(
|
|
297
|
+
userID,
|
|
298
|
+
sampleDevicePayload,
|
|
299
|
+
{ approvedByPasskeyID: created.passkeyID },
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
await expect(
|
|
303
|
+
db.isDevicePasskeyApproved(userID, device.deviceID),
|
|
304
|
+
).resolves.toBe(true);
|
|
305
|
+
await db.deleteDevice(device.deviceID);
|
|
306
|
+
await expect(
|
|
307
|
+
db.isDevicePasskeyApproved(userID, device.deviceID),
|
|
308
|
+
).resolves.toBe(false);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
238
311
|
});
|
|
239
312
|
|
|
240
313
|
describe("deletePasskey", () => {
|
package/src/db/schema.ts
CHANGED
|
@@ -18,6 +18,16 @@ export interface ChannelsTable {
|
|
|
18
18
|
|
|
19
19
|
export type ChannelUpdate = Updateable<ChannelsTable>;
|
|
20
20
|
|
|
21
|
+
export type DevicePasskeyApprovalRow = Selectable<DevicePasskeyApprovalsTable>;
|
|
22
|
+
|
|
23
|
+
export interface DevicePasskeyApprovalsTable {
|
|
24
|
+
approvedAt: string;
|
|
25
|
+
approvedByDeviceID: null | string;
|
|
26
|
+
approvedByPasskeyID: string;
|
|
27
|
+
deviceID: string;
|
|
28
|
+
userID: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
export type DeviceRow = Selectable<DevicesTable>;
|
|
22
32
|
|
|
23
33
|
export interface DevicesTable {
|
|
@@ -85,6 +95,7 @@ export type MailUpdate = Updateable<MailTable>;
|
|
|
85
95
|
export type NewChannel = Insertable<ChannelsTable>;
|
|
86
96
|
|
|
87
97
|
export type NewDevice = Insertable<DevicesTable>;
|
|
98
|
+
export type NewDevicePasskeyApproval = Insertable<DevicePasskeyApprovalsTable>;
|
|
88
99
|
export type NewEmoji = Insertable<EmojisTable>;
|
|
89
100
|
export type NewFile = Insertable<FilesTable>;
|
|
90
101
|
|
|
@@ -167,6 +178,7 @@ export interface PreKeysTable {
|
|
|
167
178
|
export type PreKeyUpdate = Updateable<PreKeysTable>;
|
|
168
179
|
export interface ServerDatabase {
|
|
169
180
|
channels: ChannelsTable;
|
|
181
|
+
device_passkey_approvals: DevicePasskeyApprovalsTable;
|
|
170
182
|
devices: DevicesTable;
|
|
171
183
|
emojis: EmojisTable;
|
|
172
184
|
files: FilesTable;
|
|
@@ -0,0 +1,30 @@
|
|
|
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 { Kysely } from "kysely";
|
|
8
|
+
|
|
9
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
10
|
+
await db.schema.dropTable("device_passkey_approvals").ifExists().execute();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
14
|
+
await db.schema
|
|
15
|
+
.createTable("device_passkey_approvals")
|
|
16
|
+
.ifNotExists()
|
|
17
|
+
.addColumn("deviceID", "varchar(255)", (cb) => cb.primaryKey())
|
|
18
|
+
.addColumn("userID", "varchar(255)", (cb) => cb.notNull())
|
|
19
|
+
.addColumn("approvedByPasskeyID", "varchar(255)", (cb) => cb.notNull())
|
|
20
|
+
.addColumn("approvedByDeviceID", "varchar(255)")
|
|
21
|
+
.addColumn("approvedAt", "text", (cb) => cb.notNull())
|
|
22
|
+
.execute();
|
|
23
|
+
|
|
24
|
+
await db.schema
|
|
25
|
+
.createIndex("device_passkey_approvals_user_idx")
|
|
26
|
+
.ifNotExists()
|
|
27
|
+
.on("device_passkey_approvals")
|
|
28
|
+
.columns(["userID", "deviceID"])
|
|
29
|
+
.execute();
|
|
30
|
+
}
|
|
@@ -106,12 +106,18 @@ export const getPasskeyDeviceRouter = (
|
|
|
106
106
|
res.sendStatus(401);
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
|
+
const passkeyID = req.passkey?.passkeyID;
|
|
110
|
+
if (!passkeyID) {
|
|
111
|
+
res.sendStatus(401);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
109
114
|
// Recovery is intentionally the only passkey-backed
|
|
110
115
|
// provisioning path: it provisions the pending device and
|
|
111
116
|
// revokes every previously-active device for the account in
|
|
112
117
|
// one server-side operation. Clients cannot accidentally
|
|
113
118
|
// restore an account while leaving lost devices trusted.
|
|
114
119
|
const result = await recoverDeviceEnrollmentRequest({
|
|
120
|
+
approvedByPasskeyID: passkeyID,
|
|
115
121
|
db,
|
|
116
122
|
notify,
|
|
117
123
|
requestID,
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
options?: { trustedDeviceID?: string },
|
|
15
|
+
): Promise<null | string> {
|
|
16
|
+
if (
|
|
17
|
+
options?.trustedDeviceID &&
|
|
18
|
+
(await db.isDevicePasskeyApproved(userID, options.trustedDeviceID))
|
|
19
|
+
) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const passkeys = await db.retrievePasskeysByUser(userID);
|
|
24
|
+
if (passkeys.length === 0) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (!passkeyID) {
|
|
28
|
+
return "Passkey verification required.";
|
|
29
|
+
}
|
|
30
|
+
const passkey = await db.retrievePasskeyInternal(passkeyID);
|
|
31
|
+
if (!passkey || passkey.userID !== userID) {
|
|
32
|
+
return mismatchError;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
package/src/server/user.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { z } from "zod/v4";
|
|
|
19
19
|
import { msgpack } from "../utils/msgpack.ts";
|
|
20
20
|
import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
|
|
21
21
|
|
|
22
|
+
import { passkeySecondFactorError } from "./passkeySecondFactor.ts";
|
|
22
23
|
import { censorUser, getParam, getUser } from "./utils.ts";
|
|
23
24
|
|
|
24
25
|
import { protect } from "./index.ts";
|
|
@@ -127,6 +128,7 @@ export function createPendingDeviceEnrollmentRequest(
|
|
|
127
128
|
* approving device's signature before invoking this helper.
|
|
128
129
|
*/
|
|
129
130
|
export async function recoverDeviceEnrollmentRequest(args: {
|
|
131
|
+
approvedByPasskeyID?: string;
|
|
130
132
|
db: Database;
|
|
131
133
|
notify: (
|
|
132
134
|
userID: string,
|
|
@@ -165,6 +167,9 @@ export async function recoverDeviceEnrollmentRequest(args: {
|
|
|
165
167
|
const { device, revokedDeviceIDs } = await args.db.recoverDevice(
|
|
166
168
|
args.userID,
|
|
167
169
|
pending.devicePayload,
|
|
170
|
+
args.approvedByPasskeyID
|
|
171
|
+
? { approvedByPasskeyID: args.approvedByPasskeyID }
|
|
172
|
+
: undefined,
|
|
168
173
|
);
|
|
169
174
|
pending.status = "approved";
|
|
170
175
|
pending.approvedDeviceID = device.deviceID;
|
|
@@ -710,6 +715,17 @@ export const getUserRouter = (
|
|
|
710
715
|
return;
|
|
711
716
|
}
|
|
712
717
|
|
|
718
|
+
const passkeyError = await passkeySecondFactorError(
|
|
719
|
+
db,
|
|
720
|
+
userID,
|
|
721
|
+
req.passkey?.passkeyID,
|
|
722
|
+
"Passkey verification does not match this account.",
|
|
723
|
+
);
|
|
724
|
+
if (passkeyError) {
|
|
725
|
+
res.status(403).send({ error: passkeyError });
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
713
729
|
const opened = await spireXSignOpenAsync(
|
|
714
730
|
XUtils.decodeHex(parsedApprove.data.signed),
|
|
715
731
|
XUtils.decodeHex(approverDevice.signKey),
|
|
@@ -732,6 +748,12 @@ export const getUserRouter = (
|
|
|
732
748
|
const device = await db.createDevice(
|
|
733
749
|
userID,
|
|
734
750
|
pending.devicePayload,
|
|
751
|
+
req.passkey
|
|
752
|
+
? {
|
|
753
|
+
approvedByDeviceID: approverDevice.deviceID,
|
|
754
|
+
approvedByPasskeyID: req.passkey.passkeyID,
|
|
755
|
+
}
|
|
756
|
+
: undefined,
|
|
735
757
|
);
|
|
736
758
|
pending.status = "approved";
|
|
737
759
|
pending.approvedDeviceID = device.deviceID;
|
package/src/types/express.ts
CHANGED
|
@@ -25,7 +25,7 @@ declare global {
|
|
|
25
25
|
* Set by `checkPasskey` middleware when the bearer token is
|
|
26
26
|
* a passkey-scoped JWT. The presence of `req.passkey`
|
|
27
27
|
* (without `req.device`) marks an admin-only request that
|
|
28
|
-
* may list/delete devices and
|
|
28
|
+
* may list/delete devices and recover/reject enrollments, but
|
|
29
29
|
* cannot send mail or do anything device-specific.
|
|
30
30
|
*/
|
|
31
31
|
passkey?: { passkeyID: string };
|