@vex-chat/spire 2.0.1 → 2.2.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/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 +5 -5
- package/src/Database.ts +86 -5
- package/src/Spire.ts +4 -20
- package/src/__tests__/Database.spec.ts +8 -1
- 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/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 };
|