@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.
Files changed (44) hide show
  1. package/README.md +9 -7
  2. package/dist/ClientManager.d.ts +1 -0
  3. package/dist/ClientManager.js +3 -0
  4. package/dist/ClientManager.js.map +1 -1
  5. package/dist/Database.d.ts +11 -0
  6. package/dist/Database.js +90 -16
  7. package/dist/Database.js.map +1 -1
  8. package/dist/Spire.d.ts +3 -0
  9. package/dist/Spire.js +155 -2
  10. package/dist/Spire.js.map +1 -1
  11. package/dist/migrations/2026-05-03_passkeys.js +2 -2
  12. package/dist/migrations/2026-05-03_passkeys.js.map +1 -1
  13. package/dist/server/index.d.ts +2 -2
  14. package/dist/server/index.js +18 -4
  15. package/dist/server/index.js.map +1 -1
  16. package/dist/server/passkey.js +24 -16
  17. package/dist/server/passkey.js.map +1 -1
  18. package/dist/server/passkeyDevices.d.ts +2 -2
  19. package/dist/server/passkeyDevices.js +14 -15
  20. package/dist/server/passkeyDevices.js.map +1 -1
  21. package/dist/server/rateLimit.d.ts +6 -2
  22. package/dist/server/rateLimit.js +13 -10
  23. package/dist/server/rateLimit.js.map +1 -1
  24. package/dist/server/user.d.ts +14 -0
  25. package/dist/server/user.js +48 -0
  26. package/dist/server/user.js.map +1 -1
  27. package/dist/utils/loadEnv.d.ts +3 -0
  28. package/dist/utils/loadEnv.js +63 -3
  29. package/dist/utils/loadEnv.js.map +1 -1
  30. package/package.json +5 -5
  31. package/src/ClientManager.ts +4 -0
  32. package/src/Database.ts +122 -16
  33. package/src/Spire.ts +230 -2
  34. package/src/__tests__/Database.spec.ts +191 -2
  35. package/src/__tests__/loadEnv.spec.ts +91 -0
  36. package/src/__tests__/passkeys.spec.ts +46 -0
  37. package/src/__tests__/rateLimit.spec.ts +60 -0
  38. package/src/migrations/2026-05-03_passkeys.ts +2 -2
  39. package/src/server/index.ts +26 -3
  40. package/src/server/passkey.ts +25 -17
  41. package/src/server/passkeyDevices.ts +17 -14
  42. package/src/server/rateLimit.ts +18 -10
  43. package/src/server/user.ts +66 -0
  44. package/src/utils/loadEnv.ts +79 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vex-chat/spire",
3
- "version": "1.11.5",
3
+ "version": "2.0.0",
4
4
  "description": "Vex server implementation in NodeJS.",
5
5
  "type": "module",
6
6
  "files": [
@@ -55,7 +55,7 @@
55
55
  "typescript-eslint": "8.58.1",
56
56
  "vitest": "4.1.4",
57
57
  "@vex-chat/eslint-config": "0.0.1",
58
- "@vex-chat/libvex": "^6.7.0"
58
+ "@vex-chat/libvex": "^7.0.0"
59
59
  },
60
60
  "typeCoverage": {
61
61
  "atLeast": 95,
@@ -74,7 +74,7 @@
74
74
  "file-type": "22.0.1",
75
75
  "helmet": "8.1.0",
76
76
  "jsonwebtoken": "9.0.3",
77
- "kysely": "^0.28.17",
77
+ "kysely": "^0.29.2",
78
78
  "morgan": "1.10.1",
79
79
  "msgpackr": "^1.11.9",
80
80
  "multer": "2.1.1",
@@ -83,8 +83,8 @@
83
83
  "uuid": "^14.0.0",
84
84
  "ws": "8.20.1",
85
85
  "zod": "^4.3.6",
86
- "@vex-chat/types": "^3.3.1",
87
- "@vex-chat/crypto": "^6.0.0"
86
+ "@vex-chat/crypto": "^7.0.0",
87
+ "@vex-chat/types": "^4.0.0"
88
88
  },
89
89
  "scripts": {
90
90
  "rebuild:sqlite": "pnpm rebuild better-sqlite3",
@@ -112,6 +112,10 @@ export class ClientManager extends EventEmitter {
112
112
  this.challenge();
113
113
  }
114
114
 
115
+ public disconnect(): void {
116
+ this.fail();
117
+ }
118
+
115
119
  public getDevice(): Device {
116
120
  if (!this.device) {
117
121
  throw new Error("No device set on this client.");
package/src/Database.ts CHANGED
@@ -441,6 +441,11 @@ export class Database extends EventEmitter {
441
441
  .where("deviceID", "=", deviceID)
442
442
  .execute();
443
443
 
444
+ await this.db
445
+ .deleteFrom("notification_subscriptions")
446
+ .where("deviceID", "=", deviceID)
447
+ .execute();
448
+
444
449
  await this.db
445
450
  .updateTable("devices")
446
451
  .set({ deleted: 1 })
@@ -747,6 +752,66 @@ export class Database extends EventEmitter {
747
752
  .executeTakeFirst();
748
753
  }
749
754
 
755
+ public async recoverDevice(
756
+ owner: string,
757
+ payload: DevicePayload,
758
+ ): Promise<{ device: Device; revokedDeviceIDs: string[] }> {
759
+ const device = {
760
+ deleted: 0,
761
+ deviceID: crypto.randomUUID(),
762
+ lastLogin: new Date().toISOString(),
763
+ name: payload.deviceName,
764
+ owner,
765
+ signKey: payload.signKey,
766
+ };
767
+ const medPreKeys = {
768
+ deviceID: device.deviceID,
769
+ index: payload.preKeyIndex,
770
+ keyID: crypto.randomUUID(),
771
+ publicKey: payload.preKey,
772
+ signature: payload.preKeySignature,
773
+ userID: owner,
774
+ };
775
+
776
+ return this.db.transaction().execute(async (trx) => {
777
+ const activeRows = await trx
778
+ .selectFrom("devices")
779
+ .select("deviceID")
780
+ .where("owner", "=", owner)
781
+ .where("deleted", "=", 0)
782
+ .execute();
783
+ const revokedDeviceIDs = activeRows.map((row) => row.deviceID);
784
+
785
+ await trx.insertInto("devices").values(device).execute();
786
+ await trx.insertInto("preKeys").values(medPreKeys).execute();
787
+
788
+ if (revokedDeviceIDs.length > 0) {
789
+ await trx
790
+ .deleteFrom("preKeys")
791
+ .where("deviceID", "in", revokedDeviceIDs)
792
+ .execute();
793
+ await trx
794
+ .deleteFrom("oneTimeKeys")
795
+ .where("deviceID", "in", revokedDeviceIDs)
796
+ .execute();
797
+ await trx
798
+ .deleteFrom("notification_subscriptions")
799
+ .where("deviceID", "in", revokedDeviceIDs)
800
+ .execute();
801
+ await trx
802
+ .updateTable("devices")
803
+ .set({ deleted: 1 })
804
+ .where("deviceID", "in", revokedDeviceIDs)
805
+ .execute();
806
+ }
807
+
808
+ return {
809
+ device: toDevice(device),
810
+ revokedDeviceIDs,
811
+ };
812
+ });
813
+ }
814
+
750
815
  public async rehashPassword(
751
816
  userID: string,
752
817
  newHash: string,
@@ -1228,22 +1293,7 @@ export class Database extends EventEmitter {
1228
1293
  deviceID: string,
1229
1294
  userID: string,
1230
1295
  ): Promise<void> {
1231
- const entry: MailSQL = {
1232
- authorID: userID,
1233
- cipher: XUtils.encodeHex(mail.cipher),
1234
- // Opaque transport metadata (X3DH initial payload or ratchet header).
1235
- extra: XUtils.encodeHex(mail.extra),
1236
- forward: mail.forward,
1237
- group: mail.group ? XUtils.encodeHex(mail.group) : null,
1238
- header: XUtils.encodeHex(header),
1239
- mailID: mail.mailID,
1240
- mailType: mail.mailType,
1241
- nonce: XUtils.encodeHex(mail.nonce),
1242
- readerID: mail.readerID,
1243
- recipient: mail.recipient,
1244
- sender: deviceID,
1245
- time: new Date().toISOString(),
1246
- };
1296
+ const entry = this.mailSqlEntry(mail, header, deviceID, userID);
1247
1297
 
1248
1298
  await this.db
1249
1299
  .insertInto("mail")
@@ -1255,6 +1305,37 @@ export class Database extends EventEmitter {
1255
1305
  .execute();
1256
1306
  }
1257
1307
 
1308
+ public async saveMailBatch(
1309
+ entries: {
1310
+ header: Uint8Array;
1311
+ mail: MailWS;
1312
+ senderDeviceID: string;
1313
+ userID: string;
1314
+ }[],
1315
+ ): Promise<void> {
1316
+ if (entries.length === 0) {
1317
+ return;
1318
+ }
1319
+
1320
+ const now = new Date().toISOString();
1321
+ const values = entries.map((entry) => {
1322
+ const mail = this.mailSqlEntry(
1323
+ entry.mail,
1324
+ entry.header,
1325
+ entry.senderDeviceID,
1326
+ entry.userID,
1327
+ now,
1328
+ );
1329
+ return {
1330
+ ...mail,
1331
+ forward: mail.forward ? 1 : 0,
1332
+ time: mail.time,
1333
+ };
1334
+ });
1335
+
1336
+ await this.db.insertInto("mail").values(values).execute();
1337
+ }
1338
+
1258
1339
  public async saveNotificationSubscription(
1259
1340
  input: SaveNotificationSubscriptionInput,
1260
1341
  ): Promise<NotificationSubscription> {
@@ -1327,6 +1408,31 @@ export class Database extends EventEmitter {
1327
1408
  }
1328
1409
  this.emit("ready");
1329
1410
  }
1411
+
1412
+ private mailSqlEntry(
1413
+ mail: MailWS,
1414
+ header: Uint8Array,
1415
+ deviceID: string,
1416
+ userID: string,
1417
+ time = new Date().toISOString(),
1418
+ ): MailSQL {
1419
+ return {
1420
+ authorID: userID,
1421
+ cipher: XUtils.encodeHex(mail.cipher),
1422
+ // Opaque transport metadata (X3DH initial payload or ratchet header).
1423
+ extra: XUtils.encodeHex(mail.extra),
1424
+ forward: mail.forward,
1425
+ group: mail.group ? XUtils.encodeHex(mail.group) : null,
1426
+ header: XUtils.encodeHex(header),
1427
+ mailID: mail.mailID,
1428
+ mailType: mail.mailType,
1429
+ nonce: XUtils.encodeHex(mail.nonce),
1430
+ readerID: mail.readerID,
1431
+ recipient: mail.recipient,
1432
+ sender: deviceID,
1433
+ time,
1434
+ };
1435
+ }
1330
1436
  }
1331
1437
 
1332
1438
  /**
package/src/Spire.ts CHANGED
@@ -4,7 +4,13 @@
4
4
  * Commercial licenses available at vex.wtf
5
5
  */
6
6
 
7
- import type { ActionToken, BaseMsg, User } from "@vex-chat/types";
7
+ import type {
8
+ ActionToken,
9
+ BaseMsg,
10
+ Device,
11
+ MailWS,
12
+ User,
13
+ } from "@vex-chat/types";
8
14
  import type { IncomingMessage, Server } from "http";
9
15
 
10
16
  import { EventEmitter } from "events";
@@ -100,6 +106,46 @@ const mailPostPayload = z.object({
100
106
  header: z.custom<Uint8Array>((val) => val instanceof Uint8Array),
101
107
  mail: MailWSSchema,
102
108
  });
109
+ const MAIL_BATCH_MAX_ITEMS = 256;
110
+ const mailBatchPostPayload = z.object({
111
+ mails: z.array(mailPostPayload).min(1).max(MAIL_BATCH_MAX_ITEMS),
112
+ });
113
+
114
+ interface MailBatchResult {
115
+ error?: string;
116
+ index: number;
117
+ mailID?: string;
118
+ ok: boolean;
119
+ recipient?: string;
120
+ status?: number;
121
+ }
122
+
123
+ interface ValidatedMailBatchEntry {
124
+ header: Uint8Array;
125
+ index: number;
126
+ mail: MailWS;
127
+ recipientDevice: Device;
128
+ }
129
+
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
+ }
103
149
 
104
150
  const notificationSubscribePayload = z.object({
105
151
  channel: z.literal("expo"),
@@ -344,6 +390,19 @@ export class Spire extends EventEmitter {
344
390
  }
345
391
  }
346
392
 
393
+ private disconnectDevices(deviceIDs: string[]): void {
394
+ if (deviceIDs.length === 0) {
395
+ return;
396
+ }
397
+ const ids = new Set(deviceIDs);
398
+ for (const client of [...this.clients]) {
399
+ const deviceID = client.getDeviceID();
400
+ if (deviceID !== null && ids.has(deviceID)) {
401
+ client.disconnect();
402
+ }
403
+ }
404
+ }
405
+
347
406
  private init(apiPort: number): void {
348
407
  // Request traces (UUIDs and device public-key path segments redacted
349
408
  // in the `url` token). Enabled in all envs, including production.
@@ -381,6 +440,7 @@ export class Spire extends EventEmitter {
381
440
  this.validateToken.bind(this),
382
441
  this.signKeys,
383
442
  this.notify.bind(this),
443
+ this.disconnectDevices.bind(this),
384
444
  );
385
445
 
386
446
  // WS auth: client sends { type: "auth", token } as first message
@@ -748,6 +808,22 @@ export class Spire extends EventEmitter {
748
808
  .send({ error: "Device owner not found." });
749
809
  }
750
810
 
811
+ const passkeyError = await passkeySecondFactorError(
812
+ this.db,
813
+ user.userID,
814
+ req.passkey?.passkeyID,
815
+ "Passkey verification does not match this device.",
816
+ );
817
+ if (passkeyError) {
818
+ const body: { error: string; username?: string } = {
819
+ error: passkeyError,
820
+ };
821
+ if (passkeyError === "Passkey verification required.") {
822
+ body.username = user.username;
823
+ }
824
+ return res.status(403).send(body);
825
+ }
826
+
751
827
  // Issue short-lived JWT (1 hour, not 7 days)
752
828
  const token = jwt.sign(
753
829
  { user: censorUser(user) },
@@ -817,6 +893,125 @@ export class Spire extends EventEmitter {
817
893
  );
818
894
  });
819
895
 
896
+ this.api.post("/mail/batch", protect, async (req, res) => {
897
+ const senderDeviceDetails = req.device;
898
+ if (!senderDeviceDetails) {
899
+ res.sendStatus(401);
900
+ return;
901
+ }
902
+ const authorUserDetails = getUser(req);
903
+
904
+ const parsed = mailBatchPostPayload.safeParse(req.body);
905
+ if (!parsed.success) {
906
+ res.status(400).json({
907
+ error: "Invalid mail batch payload",
908
+ issues: parsed.error.issues,
909
+ });
910
+ return;
911
+ }
912
+
913
+ const results: MailBatchResult[] = [];
914
+ const validEntries: ValidatedMailBatchEntry[] = [];
915
+ for (const [index, item] of parsed.data.mails.entries()) {
916
+ const { header, mail } = item;
917
+ try {
918
+ const { recipientDevice } = await validateMailIngress(
919
+ this.db,
920
+ mail,
921
+ senderDeviceDetails.deviceID,
922
+ authorUserDetails.userID,
923
+ );
924
+ validEntries.push({
925
+ header,
926
+ index,
927
+ mail,
928
+ recipientDevice,
929
+ });
930
+ } catch (err: unknown) {
931
+ const status =
932
+ err instanceof MailIngressValidationError
933
+ ? err.status
934
+ : 500;
935
+ const message =
936
+ err instanceof Error ? err.message : String(err);
937
+ results[index] = {
938
+ error: message,
939
+ index,
940
+ mailID: mail.mailID,
941
+ ok: false,
942
+ recipient: mail.recipient,
943
+ status,
944
+ };
945
+ }
946
+ }
947
+
948
+ const deliveredEntries: ValidatedMailBatchEntry[] = [];
949
+ if (validEntries.length > 0) {
950
+ try {
951
+ await this.db.saveMailBatch(
952
+ validEntries.map((entry) => ({
953
+ header: entry.header,
954
+ mail: entry.mail,
955
+ senderDeviceID: senderDeviceDetails.deviceID,
956
+ userID: authorUserDetails.userID,
957
+ })),
958
+ );
959
+ for (const entry of validEntries) {
960
+ deliveredEntries.push(entry);
961
+ results[entry.index] = {
962
+ index: entry.index,
963
+ mailID: entry.mail.mailID,
964
+ ok: true,
965
+ recipient: entry.mail.recipient,
966
+ };
967
+ }
968
+ } catch {
969
+ for (const entry of validEntries) {
970
+ try {
971
+ await this.db.saveMail(
972
+ entry.mail,
973
+ entry.header,
974
+ senderDeviceDetails.deviceID,
975
+ authorUserDetails.userID,
976
+ );
977
+ deliveredEntries.push(entry);
978
+ results[entry.index] = {
979
+ index: entry.index,
980
+ mailID: entry.mail.mailID,
981
+ ok: true,
982
+ recipient: entry.mail.recipient,
983
+ };
984
+ } catch (err: unknown) {
985
+ results[entry.index] = {
986
+ error:
987
+ err instanceof Error
988
+ ? err.message
989
+ : String(err),
990
+ index: entry.index,
991
+ mailID: entry.mail.mailID,
992
+ ok: false,
993
+ recipient: entry.mail.recipient,
994
+ status: 500,
995
+ };
996
+ }
997
+ }
998
+ }
999
+ }
1000
+
1001
+ res.send(msgpack.encode({ results }));
1002
+ for (const entry of deliveredEntries) {
1003
+ this.notify(
1004
+ entry.recipientDevice.owner,
1005
+ "mail",
1006
+ crypto.randomUUID(),
1007
+ null,
1008
+ entry.mail.recipient,
1009
+ entry.mail.authorID,
1010
+ entry.mail.nonce,
1011
+ );
1012
+ }
1013
+ });
1014
+
820
1015
  this.api.post(
821
1016
  "/device/:id/notifications/subscriptions",
822
1017
  protect,
@@ -910,6 +1105,19 @@ export class Spire extends EventEmitter {
910
1105
  await this.db.rehashPassword(userEntry.userID, newHash);
911
1106
  }
912
1107
 
1108
+ const passkeyError = await passkeySecondFactorError(
1109
+ this.db,
1110
+ userEntry.userID,
1111
+ req.passkey?.passkeyID,
1112
+ "Passkey verification does not match this account.",
1113
+ );
1114
+ if (passkeyError) {
1115
+ res.status(403).send({
1116
+ error: passkeyError,
1117
+ });
1118
+ return;
1119
+ }
1120
+
913
1121
  const token = jwt.sign(
914
1122
  { user: censorUser(userEntry) },
915
1123
  getJwtSecret(),
@@ -1033,7 +1241,27 @@ export class Spire extends EventEmitter {
1033
1241
  res.sendStatus(500);
1034
1242
  return;
1035
1243
  }
1036
- res.send(msgpack.encode(censorUser(user)));
1244
+ const device = await this.db.retrieveDevice(
1245
+ normalizedPayload.signKey,
1246
+ );
1247
+ if (!device) {
1248
+ res.sendStatus(500);
1249
+ return;
1250
+ }
1251
+ const censored = censorUser(user);
1252
+ const token = jwt.sign(
1253
+ { user: censored },
1254
+ getJwtSecret(),
1255
+ { expiresIn: JWT_EXPIRY },
1256
+ );
1257
+ jwt.verify(token, getJwtSecret());
1258
+ res.send(
1259
+ msgpack.encode({
1260
+ device,
1261
+ token,
1262
+ user: censored,
1263
+ }),
1264
+ );
1037
1265
  }
1038
1266
  } else if (regKey && regKey.length !== 16) {
1039
1267
  res.status(400).send({