@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/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: new Date().toISOString(),
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.insertInto("preKeys").values(medPreKeys).execute();
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: new Date().toISOString(),
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(8);
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
+ }
@@ -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 };