@vex-chat/spire 2.0.1 → 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/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
+ }
@@ -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;
@@ -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 approve enrollments, but
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 };