@vex-chat/spire 1.11.4 → 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.
- package/README.md +9 -7
- package/dist/ClientManager.d.ts +2 -1
- package/dist/ClientManager.js +4 -1
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +12 -0
- package/dist/Database.js +100 -16
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.d.ts +4 -0
- package/dist/NotificationService.js +54 -10
- package/dist/NotificationService.js.map +1 -1
- package/dist/Spire.d.ts +3 -0
- package/dist/Spire.js +158 -4
- 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 +7 -0
- package/src/Database.ts +136 -16
- package/src/NotificationService.ts +84 -11
- package/src/Spire.ts +233 -2
- package/src/__tests__/Database.spec.ts +191 -2
- package/src/__tests__/loadEnv.spec.ts +91 -0
- package/src/__tests__/notifyFanout.spec.ts +109 -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/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) },
|
|
@@ -813,9 +889,129 @@ export class Spire extends EventEmitter {
|
|
|
813
889
|
null,
|
|
814
890
|
mail.recipient,
|
|
815
891
|
mail.authorID,
|
|
892
|
+
mail.nonce,
|
|
816
893
|
);
|
|
817
894
|
});
|
|
818
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
|
+
|
|
819
1015
|
this.api.post(
|
|
820
1016
|
"/device/:id/notifications/subscriptions",
|
|
821
1017
|
protect,
|
|
@@ -909,6 +1105,19 @@ export class Spire extends EventEmitter {
|
|
|
909
1105
|
await this.db.rehashPassword(userEntry.userID, newHash);
|
|
910
1106
|
}
|
|
911
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
|
+
|
|
912
1121
|
const token = jwt.sign(
|
|
913
1122
|
{ user: censorUser(userEntry) },
|
|
914
1123
|
getJwtSecret(),
|
|
@@ -1032,7 +1241,27 @@ export class Spire extends EventEmitter {
|
|
|
1032
1241
|
res.sendStatus(500);
|
|
1033
1242
|
return;
|
|
1034
1243
|
}
|
|
1035
|
-
|
|
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
|
+
);
|
|
1036
1265
|
}
|
|
1037
1266
|
} else if (regKey && regKey.length !== 16) {
|
|
1038
1267
|
res.status(400).send({
|
|
@@ -1076,11 +1305,13 @@ export class Spire extends EventEmitter {
|
|
|
1076
1305
|
data?: unknown,
|
|
1077
1306
|
deviceID?: string,
|
|
1078
1307
|
headlessPushUserID?: string,
|
|
1308
|
+
mailNonce?: Uint8Array,
|
|
1079
1309
|
): void {
|
|
1080
1310
|
this.notifications.notify({
|
|
1081
1311
|
data,
|
|
1082
1312
|
event,
|
|
1083
1313
|
...(headlessPushUserID ? { headlessPushUserID } : {}),
|
|
1314
|
+
...(mailNonce ? { mailNonce } : {}),
|
|
1084
1315
|
transmissionID,
|
|
1085
1316
|
userID,
|
|
1086
1317
|
...(deviceID ? { deviceID } : {}),
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { SpireOptions } from "../Spire.ts";
|
|
8
|
-
import type { MailWS, PreKeysWS } from "@vex-chat/types";
|
|
8
|
+
import type { DevicePayload, MailWS, PreKeysWS } from "@vex-chat/types";
|
|
9
9
|
|
|
10
10
|
import { XUtils } from "@vex-chat/crypto";
|
|
11
11
|
import { MailType } from "@vex-chat/types";
|
|
@@ -64,6 +64,19 @@ describe("Database", () => {
|
|
|
64
64
|
signature,
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
const devicePayload = (
|
|
68
|
+
deviceName: string,
|
|
69
|
+
signKey: string,
|
|
70
|
+
): DevicePayload => ({
|
|
71
|
+
deviceName,
|
|
72
|
+
preKey: testSQLPreKey.publicKey,
|
|
73
|
+
preKeyIndex: 1,
|
|
74
|
+
preKeySignature: testSQLPreKey.signature,
|
|
75
|
+
signed: "00",
|
|
76
|
+
signKey,
|
|
77
|
+
username: "alice",
|
|
78
|
+
});
|
|
79
|
+
|
|
67
80
|
const options: SpireOptions = {
|
|
68
81
|
dbType: "sqlite3mem",
|
|
69
82
|
};
|
|
@@ -156,6 +169,100 @@ describe("Database", () => {
|
|
|
156
169
|
});
|
|
157
170
|
});
|
|
158
171
|
|
|
172
|
+
describe("recoverDevice", () => {
|
|
173
|
+
it("creates a new device and revokes all previous devices for the user", async () => {
|
|
174
|
+
expect.assertions(8);
|
|
175
|
+
|
|
176
|
+
const provider = new Database(options);
|
|
177
|
+
await new Promise<void>((resolve, reject) => {
|
|
178
|
+
provider.once("ready", () => {
|
|
179
|
+
void (async () => {
|
|
180
|
+
try {
|
|
181
|
+
const oldA = await provider.createDevice(
|
|
182
|
+
userID,
|
|
183
|
+
devicePayload("old-a", "a".repeat(64)),
|
|
184
|
+
);
|
|
185
|
+
const oldB = await provider.createDevice(
|
|
186
|
+
userID,
|
|
187
|
+
devicePayload("old-b", "b".repeat(64)),
|
|
188
|
+
);
|
|
189
|
+
await provider.saveOTK(userID, oldA.deviceID, [
|
|
190
|
+
{
|
|
191
|
+
deviceID: oldA.deviceID,
|
|
192
|
+
index: 1,
|
|
193
|
+
publicKey,
|
|
194
|
+
signature,
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
await provider.saveNotificationSubscription({
|
|
198
|
+
channel: "expo",
|
|
199
|
+
deviceID: oldA.deviceID,
|
|
200
|
+
events: ["deviceRequest"],
|
|
201
|
+
platform: "ios",
|
|
202
|
+
token: "ExponentPushToken[old-a]",
|
|
203
|
+
userID,
|
|
204
|
+
});
|
|
205
|
+
await provider.saveNotificationSubscription({
|
|
206
|
+
channel: "expo",
|
|
207
|
+
deviceID: oldB.deviceID,
|
|
208
|
+
events: ["*"],
|
|
209
|
+
platform: "android",
|
|
210
|
+
token: "ExponentPushToken[old-b]",
|
|
211
|
+
userID,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const recovered = await provider.recoverDevice(
|
|
215
|
+
userID,
|
|
216
|
+
devicePayload("recovered", "c".repeat(64)),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(recovered.revokedDeviceIDs.sort()).toEqual(
|
|
220
|
+
[oldA.deviceID, oldB.deviceID].sort(),
|
|
221
|
+
);
|
|
222
|
+
expect(
|
|
223
|
+
await provider.retrieveDevice(oldA.deviceID),
|
|
224
|
+
).toBeNull();
|
|
225
|
+
expect(
|
|
226
|
+
await provider.retrieveDevice(oldB.deviceID),
|
|
227
|
+
).toBeNull();
|
|
228
|
+
expect(
|
|
229
|
+
await provider.retrieveDevice(
|
|
230
|
+
recovered.device.deviceID,
|
|
231
|
+
),
|
|
232
|
+
).toEqual(recovered.device);
|
|
233
|
+
expect(
|
|
234
|
+
await provider.retrieveUserDeviceList([userID]),
|
|
235
|
+
).toEqual([recovered.device]);
|
|
236
|
+
expect(
|
|
237
|
+
await provider.getPreKeys(oldA.deviceID),
|
|
238
|
+
).toBeNull();
|
|
239
|
+
expect(
|
|
240
|
+
await provider.getOTK(oldA.deviceID),
|
|
241
|
+
).toBeNull();
|
|
242
|
+
expect(
|
|
243
|
+
await provider.retrieveNotificationSubscriptions(
|
|
244
|
+
{
|
|
245
|
+
event: "deviceRequest",
|
|
246
|
+
userID,
|
|
247
|
+
},
|
|
248
|
+
),
|
|
249
|
+
).toEqual([]);
|
|
250
|
+
await provider.close();
|
|
251
|
+
resolve();
|
|
252
|
+
} catch (e: unknown) {
|
|
253
|
+
await provider.close().catch(() => {
|
|
254
|
+
/* ignore cleanup failure */
|
|
255
|
+
});
|
|
256
|
+
reject(
|
|
257
|
+
e instanceof Error ? e : new Error(String(e)),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
})();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
159
266
|
describe("retrieveMail", () => {
|
|
160
267
|
it("returns queued mail in send order for logged-out batch drains", async () => {
|
|
161
268
|
expect.assertions(1);
|
|
@@ -231,9 +338,80 @@ describe("Database", () => {
|
|
|
231
338
|
});
|
|
232
339
|
});
|
|
233
340
|
|
|
341
|
+
describe("saveMailBatch", () => {
|
|
342
|
+
it("stores multiple queued mail rows in one insert", async () => {
|
|
343
|
+
expect.assertions(1);
|
|
344
|
+
|
|
345
|
+
const provider = new Database(options);
|
|
346
|
+
await new Promise<void>((resolve, reject) => {
|
|
347
|
+
provider.once("ready", () => {
|
|
348
|
+
void (async () => {
|
|
349
|
+
try {
|
|
350
|
+
const mail = (
|
|
351
|
+
mailID: string,
|
|
352
|
+
nonce: number,
|
|
353
|
+
): MailWS => ({
|
|
354
|
+
authorID: userID,
|
|
355
|
+
cipher: new Uint8Array([1, nonce]),
|
|
356
|
+
extra: new Uint8Array([2, nonce]),
|
|
357
|
+
forward: false,
|
|
358
|
+
group: null,
|
|
359
|
+
mailID,
|
|
360
|
+
mailType: MailType.initial,
|
|
361
|
+
nonce: new Uint8Array([3, nonce]),
|
|
362
|
+
readerID: userID,
|
|
363
|
+
recipient: deviceID,
|
|
364
|
+
sender: "sender-a",
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await provider.saveMailBatch([
|
|
368
|
+
{
|
|
369
|
+
header: new Uint8Array([4, 1]),
|
|
370
|
+
mail: mail(
|
|
371
|
+
"00000000-0000-0000-0000-000000000011",
|
|
372
|
+
1,
|
|
373
|
+
),
|
|
374
|
+
senderDeviceID: "sender-a",
|
|
375
|
+
userID,
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
header: new Uint8Array([4, 2]),
|
|
379
|
+
mail: mail(
|
|
380
|
+
"00000000-0000-0000-0000-000000000012",
|
|
381
|
+
2,
|
|
382
|
+
),
|
|
383
|
+
senderDeviceID: "sender-a",
|
|
384
|
+
userID,
|
|
385
|
+
},
|
|
386
|
+
]);
|
|
387
|
+
|
|
388
|
+
const result =
|
|
389
|
+
await provider.retrieveMail(deviceID);
|
|
390
|
+
expect(
|
|
391
|
+
result.map(
|
|
392
|
+
([, body]: [Uint8Array, MailWS, string]) =>
|
|
393
|
+
body.mailID,
|
|
394
|
+
),
|
|
395
|
+
).toEqual([
|
|
396
|
+
"00000000-0000-0000-0000-000000000011",
|
|
397
|
+
"00000000-0000-0000-0000-000000000012",
|
|
398
|
+
]);
|
|
399
|
+
await provider.close();
|
|
400
|
+
resolve();
|
|
401
|
+
} catch (e: unknown) {
|
|
402
|
+
reject(
|
|
403
|
+
e instanceof Error ? e : new Error(String(e)),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
})();
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
234
412
|
describe("notification subscriptions", () => {
|
|
235
413
|
it("upserts and filters Expo push subscriptions by event", async () => {
|
|
236
|
-
expect.assertions(
|
|
414
|
+
expect.assertions(8);
|
|
237
415
|
|
|
238
416
|
const provider = new Database(options);
|
|
239
417
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -297,6 +475,17 @@ describe("Database", () => {
|
|
|
297
475
|
);
|
|
298
476
|
expect(deviceSubsAfterUpdate).toHaveLength(1);
|
|
299
477
|
|
|
478
|
+
await provider.deleteDevice(deviceID);
|
|
479
|
+
expect(
|
|
480
|
+
await provider.retrieveNotificationSubscriptions(
|
|
481
|
+
{
|
|
482
|
+
deviceID,
|
|
483
|
+
event: "deviceRequest",
|
|
484
|
+
userID,
|
|
485
|
+
},
|
|
486
|
+
),
|
|
487
|
+
).toEqual([]);
|
|
488
|
+
|
|
300
489
|
await provider.close();
|
|
301
490
|
resolve();
|
|
302
491
|
} catch (e: unknown) {
|
|
@@ -0,0 +1,91 @@
|
|
|
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 { describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
isSpireFipsEnabled,
|
|
11
|
+
normalizeEnvValue,
|
|
12
|
+
validateSpireRuntimeEnv,
|
|
13
|
+
} from "../utils/loadEnv.ts";
|
|
14
|
+
|
|
15
|
+
const TWEETNACL_SPK = "ab".repeat(64);
|
|
16
|
+
const FIPS_SPK = "cd".repeat(90);
|
|
17
|
+
const JWT_SECRET = "ef".repeat(32);
|
|
18
|
+
|
|
19
|
+
describe("normalizeEnvValue", () => {
|
|
20
|
+
it("strips matching quotes from compose/dotenv values", () => {
|
|
21
|
+
expect(normalizeEnvValue(`"${TWEETNACL_SPK}"`)).toBe(TWEETNACL_SPK);
|
|
22
|
+
expect(normalizeEnvValue(`'${JWT_SECRET}'`)).toBe(JWT_SECRET);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("trims whitespace without stripping unmatched quotes", () => {
|
|
26
|
+
expect(normalizeEnvValue(` ${TWEETNACL_SPK} `)).toBe(TWEETNACL_SPK);
|
|
27
|
+
expect(normalizeEnvValue(`"${TWEETNACL_SPK}`)).toBe(
|
|
28
|
+
`"${TWEETNACL_SPK}`,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isSpireFipsEnabled", () => {
|
|
34
|
+
it("accepts true and 1 only", () => {
|
|
35
|
+
expect(isSpireFipsEnabled("true")).toBe(true);
|
|
36
|
+
expect(isSpireFipsEnabled('"true"')).toBe(true);
|
|
37
|
+
expect(isSpireFipsEnabled("1")).toBe(true);
|
|
38
|
+
expect(isSpireFipsEnabled("false")).toBe(false);
|
|
39
|
+
expect(isSpireFipsEnabled(undefined)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("validateSpireRuntimeEnv", () => {
|
|
44
|
+
it("accepts quoted compose-safe tweetnacl keys", () => {
|
|
45
|
+
expect(() => {
|
|
46
|
+
validateSpireRuntimeEnv({
|
|
47
|
+
JWT_SECRET: `"${JWT_SECRET}"`,
|
|
48
|
+
SPIRE_FIPS: "false",
|
|
49
|
+
SPK: `"${TWEETNACL_SPK}"`,
|
|
50
|
+
});
|
|
51
|
+
}).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects non-hex SPK values before crypto init", () => {
|
|
55
|
+
expect(() => {
|
|
56
|
+
validateSpireRuntimeEnv({
|
|
57
|
+
JWT_SECRET,
|
|
58
|
+
SPK: `"${TWEETNACL_SPK}`,
|
|
59
|
+
});
|
|
60
|
+
}).toThrow(/SPK must be an even-length hex string/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("rejects a FIPS flag with a tweetnacl-sized SPK", () => {
|
|
64
|
+
expect(() => {
|
|
65
|
+
validateSpireRuntimeEnv({
|
|
66
|
+
JWT_SECRET,
|
|
67
|
+
SPIRE_FIPS: "true",
|
|
68
|
+
SPK: TWEETNACL_SPK,
|
|
69
|
+
});
|
|
70
|
+
}).toThrow(/requires an SPK from .*gen-spk-fips/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects a FIPS-sized SPK without the FIPS flag", () => {
|
|
74
|
+
expect(() => {
|
|
75
|
+
validateSpireRuntimeEnv({
|
|
76
|
+
JWT_SECRET,
|
|
77
|
+
SPIRE_FIPS: "false",
|
|
78
|
+
SPK: FIPS_SPK,
|
|
79
|
+
});
|
|
80
|
+
}).toThrow(/SPK must be 128 hex characters/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects reusing SPK as JWT_SECRET", () => {
|
|
84
|
+
expect(() => {
|
|
85
|
+
validateSpireRuntimeEnv({
|
|
86
|
+
JWT_SECRET: TWEETNACL_SPK,
|
|
87
|
+
SPK: TWEETNACL_SPK,
|
|
88
|
+
});
|
|
89
|
+
}).toThrow(/JWT_SECRET must be separate from SPK/);
|
|
90
|
+
});
|
|
91
|
+
});
|