@vex-chat/spire 1.11.5 → 2.0.1
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/README.md +9 -7
- package/dist/ClientManager.d.ts +1 -0
- package/dist/ClientManager.js +3 -0
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +11 -0
- package/dist/Database.js +90 -16
- 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 +3 -0
- package/dist/Spire.js +155 -2
- package/dist/Spire.js.map +1 -1
- package/dist/migrations/2026-05-03_passkeys.js +2 -2
- package/dist/migrations/2026-05-03_passkeys.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +18 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +24 -16
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.d.ts +2 -2
- package/dist/server/passkeyDevices.js +14 -15
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/rateLimit.d.ts +6 -2
- package/dist/server/rateLimit.js +13 -10
- package/dist/server/rateLimit.js.map +1 -1
- package/dist/server/user.d.ts +14 -0
- package/dist/server/user.js +48 -0
- package/dist/server/user.js.map +1 -1
- package/dist/utils/loadEnv.d.ts +3 -0
- package/dist/utils/loadEnv.js +63 -3
- package/dist/utils/loadEnv.js.map +1 -1
- package/package.json +6 -6
- package/src/ClientManager.ts +4 -0
- package/src/Database.ts +122 -16
- package/src/NotificationService.ts +3 -4
- package/src/Spire.ts +230 -2
- package/src/__tests__/Database.spec.ts +191 -2
- package/src/__tests__/loadEnv.spec.ts +91 -0
- package/src/__tests__/notifyFanout.spec.ts +44 -0
- package/src/__tests__/passkeys.spec.ts +46 -0
- package/src/__tests__/rateLimit.spec.ts +60 -0
- package/src/migrations/2026-05-03_passkeys.ts +2 -2
- package/src/server/index.ts +26 -3
- package/src/server/passkey.ts +25 -17
- package/src/server/passkeyDevices.ts +17 -14
- package/src/server/rateLimit.ts +18 -10
- package/src/server/user.ts +66 -0
- 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": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Vex server implementation in NodeJS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"typescript": "6.0.2",
|
|
55
55
|
"typescript-eslint": "8.58.1",
|
|
56
56
|
"vitest": "4.1.4",
|
|
57
|
-
"@vex-chat/
|
|
58
|
-
"@vex-chat/
|
|
57
|
+
"@vex-chat/libvex": "^7.0.0",
|
|
58
|
+
"@vex-chat/eslint-config": "0.0.1"
|
|
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.
|
|
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/
|
|
87
|
-
"@vex-chat/
|
|
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",
|
package/src/ClientManager.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|
|
@@ -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
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
* Commercial licenses available at vex.wtf
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
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
|
-
|
|
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({
|