@vex-chat/spire 2.3.0 → 2.3.2
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/dist/Database.d.ts +1 -0
- package/dist/Database.js +17 -0
- package/dist/Database.js.map +1 -1
- package/dist/Spire.d.ts +1 -1
- package/dist/Spire.js +25 -2
- package/dist/Spire.js.map +1 -1
- package/dist/server/index.js +52 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/user.d.ts +1 -0
- package/dist/server/user.js +24 -4
- package/dist/server/user.js.map +1 -1
- package/dist/utils/preKeySignature.d.ts +8 -0
- package/dist/utils/preKeySignature.js +33 -0
- package/dist/utils/preKeySignature.js.map +1 -0
- package/package.json +2 -2
- package/src/Database.ts +22 -0
- package/src/Spire.ts +35 -2
- package/src/__tests__/preKeySignature.spec.ts +74 -0
- package/src/server/index.ts +62 -7
- package/src/server/user.ts +40 -4
- package/src/utils/preKeySignature.ts +62 -0
package/src/server/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { POWER_LEVELS } from "../ClientManager.ts";
|
|
|
28
28
|
import { JWT_EXPIRY } from "../Spire.ts";
|
|
29
29
|
import { getJwtSecret } from "../utils/jwtSecret.ts";
|
|
30
30
|
import { msgpack } from "../utils/msgpack.ts";
|
|
31
|
+
import { verifyPreKeyWsSignature } from "../utils/preKeySignature.ts";
|
|
31
32
|
import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
|
|
32
33
|
|
|
33
34
|
import { getAvatarRouter } from "./avatar.ts";
|
|
@@ -716,6 +717,49 @@ export const initApp = (
|
|
|
716
717
|
res.send(msgpack.encode({ count }));
|
|
717
718
|
});
|
|
718
719
|
|
|
720
|
+
api.post("/device/:id/prekey", protect, async (req, res) => {
|
|
721
|
+
const parsedPreKey = PreKeysWSSchema.safeParse(req.body);
|
|
722
|
+
if (!parsedPreKey.success) {
|
|
723
|
+
res.status(400).json({
|
|
724
|
+
error: "Invalid prekey payload",
|
|
725
|
+
issues: parsedPreKey.error.issues,
|
|
726
|
+
});
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const deviceID = getParam(req, "id");
|
|
731
|
+
const device = await db.retrieveDevice(deviceID);
|
|
732
|
+
const deviceDetails = req.device;
|
|
733
|
+
if (!device) {
|
|
734
|
+
res.sendStatus(404);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (!deviceDetails) {
|
|
738
|
+
res.sendStatus(401);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (
|
|
742
|
+
deviceDetails.deviceID !== deviceID ||
|
|
743
|
+
req.user?.userID !== device.owner
|
|
744
|
+
) {
|
|
745
|
+
res.sendStatus(401);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const preKey = parsedPreKey.data;
|
|
750
|
+
if (preKey.deviceID !== deviceID) {
|
|
751
|
+
res.status(400).send({ error: "Prekey deviceID mismatch." });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (!(await verifyPreKeyWsSignature(preKey, device.signKey))) {
|
|
755
|
+
res.status(401).send({ error: "Prekey signature invalid." });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
await db.replacePreKey(device.owner, deviceID, preKey);
|
|
760
|
+
res.sendStatus(200);
|
|
761
|
+
});
|
|
762
|
+
|
|
719
763
|
api.post("/device/:id/otk", protect, async (req, res) => {
|
|
720
764
|
const parsedOTKs = z.array(PreKeysWSSchema).safeParse(req.body);
|
|
721
765
|
if (!parsedOTKs.success) {
|
|
@@ -732,6 +776,7 @@ export const initApp = (
|
|
|
732
776
|
}
|
|
733
777
|
|
|
734
778
|
const userDetails = getUser(req);
|
|
779
|
+
const deviceDetails = req.device;
|
|
735
780
|
|
|
736
781
|
const deviceID = getParam(req, "id");
|
|
737
782
|
const otk = submittedOTKs[0];
|
|
@@ -741,16 +786,26 @@ export const initApp = (
|
|
|
741
786
|
res.sendStatus(404);
|
|
742
787
|
return;
|
|
743
788
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
)
|
|
749
|
-
|
|
750
|
-
if (!message) {
|
|
789
|
+
if (
|
|
790
|
+
!deviceDetails ||
|
|
791
|
+
deviceDetails.deviceID !== deviceID ||
|
|
792
|
+
userDetails.userID !== device.owner
|
|
793
|
+
) {
|
|
751
794
|
res.sendStatus(401);
|
|
752
795
|
return;
|
|
753
796
|
}
|
|
797
|
+
for (const submittedOTK of submittedOTKs) {
|
|
798
|
+
if (submittedOTK.deviceID !== deviceID) {
|
|
799
|
+
res.status(400).send({ error: "OTK deviceID mismatch." });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (
|
|
803
|
+
!(await verifyPreKeyWsSignature(submittedOTK, device.signKey))
|
|
804
|
+
) {
|
|
805
|
+
res.status(401).send({ error: "OTK signature invalid." });
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
754
809
|
|
|
755
810
|
await db.saveOTK(userDetails.userID, deviceID, submittedOTKs);
|
|
756
811
|
res.sendStatus(200);
|
package/src/server/user.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { stringify } from "uuid";
|
|
|
25
25
|
import { z } from "zod/v4";
|
|
26
26
|
|
|
27
27
|
import { msgpack } from "../utils/msgpack.ts";
|
|
28
|
+
import { verifyDevicePayloadPreKeySignature } from "../utils/preKeySignature.ts";
|
|
28
29
|
import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
|
|
29
30
|
|
|
30
31
|
import { AppError } from "./errors.ts";
|
|
@@ -51,6 +52,11 @@ interface DeviceEnrollmentRequest {
|
|
|
51
52
|
*/
|
|
52
53
|
ownerNotified?: boolean;
|
|
53
54
|
passkeyRegistration?: PendingDevicePasskeyRegistration;
|
|
55
|
+
/**
|
|
56
|
+
* Set when the requesting device proved account ownership with a passkey
|
|
57
|
+
* before asking the existing device cluster for membership approval.
|
|
58
|
+
*/
|
|
59
|
+
requesterPasskeyID?: string;
|
|
54
60
|
requestID: string;
|
|
55
61
|
resolvedAt?: number;
|
|
56
62
|
status: DeviceEnrollmentStatus;
|
|
@@ -111,7 +117,10 @@ export function createPendingDeviceEnrollmentRequest(
|
|
|
111
117
|
data?: unknown,
|
|
112
118
|
deviceID?: string,
|
|
113
119
|
) => void,
|
|
114
|
-
options?: {
|
|
120
|
+
options?: {
|
|
121
|
+
deferOwnerNotification?: boolean;
|
|
122
|
+
requesterPasskeyID?: string;
|
|
123
|
+
},
|
|
115
124
|
): {
|
|
116
125
|
challenge: string;
|
|
117
126
|
expiresAt: string;
|
|
@@ -128,6 +137,9 @@ export function createPendingDeviceEnrollmentRequest(
|
|
|
128
137
|
createdAt: Date.now(),
|
|
129
138
|
devicePayload,
|
|
130
139
|
requestID,
|
|
140
|
+
...(options?.requesterPasskeyID
|
|
141
|
+
? { requesterPasskeyID: options.requesterPasskeyID }
|
|
142
|
+
: {}),
|
|
131
143
|
status: "pending",
|
|
132
144
|
userID,
|
|
133
145
|
...(deferOwner ? { ownerNotified: false } : { ownerNotified: true }),
|
|
@@ -883,6 +895,10 @@ export const getUserRouter = (
|
|
|
883
895
|
}
|
|
884
896
|
|
|
885
897
|
if (tokenValidator(stringify(token), TokenScopes.Device)) {
|
|
898
|
+
if (!(await verifyDevicePayloadPreKeySignature(deviceData))) {
|
|
899
|
+
res.status(400).send({ error: "Prekey signature invalid." });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
886
902
|
const userDevices = await db.retrieveUserDeviceList([
|
|
887
903
|
userDetails.userID,
|
|
888
904
|
]);
|
|
@@ -901,10 +917,26 @@ export const getUserRouter = (
|
|
|
901
917
|
}
|
|
902
918
|
}
|
|
903
919
|
|
|
920
|
+
let requesterPasskeyID: string | undefined;
|
|
921
|
+
if (req.passkey?.passkeyID) {
|
|
922
|
+
const passkeyError = await passkeySecondFactorError(
|
|
923
|
+
db,
|
|
924
|
+
userDetails.userID,
|
|
925
|
+
req.passkey.passkeyID,
|
|
926
|
+
"Passkey verification does not match this account.",
|
|
927
|
+
);
|
|
928
|
+
if (passkeyError) {
|
|
929
|
+
res.status(403).send({ error: passkeyError });
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
requesterPasskeyID = req.passkey.passkeyID;
|
|
933
|
+
}
|
|
934
|
+
|
|
904
935
|
const pendingResponse = createPendingDeviceEnrollmentRequest(
|
|
905
936
|
userDetails.userID,
|
|
906
937
|
deviceData,
|
|
907
938
|
notify,
|
|
939
|
+
requesterPasskeyID ? { requesterPasskeyID } : undefined,
|
|
908
940
|
);
|
|
909
941
|
res.status(202).send(msgpack.encode(pendingResponse));
|
|
910
942
|
} else {
|
|
@@ -1001,10 +1033,14 @@ export const getUserRouter = (
|
|
|
1001
1033
|
return;
|
|
1002
1034
|
}
|
|
1003
1035
|
|
|
1036
|
+
// New clients put the passkey proof on the requesting device.
|
|
1037
|
+
// Older clients may still satisfy this with an approval-side passkey.
|
|
1038
|
+
const approvedByPasskeyID =
|
|
1039
|
+
pending.requesterPasskeyID ?? req.passkey?.passkeyID;
|
|
1004
1040
|
const passkeyError = await passkeySecondFactorError(
|
|
1005
1041
|
db,
|
|
1006
1042
|
userID,
|
|
1007
|
-
|
|
1043
|
+
approvedByPasskeyID,
|
|
1008
1044
|
"Passkey verification does not match this account.",
|
|
1009
1045
|
);
|
|
1010
1046
|
if (passkeyError) {
|
|
@@ -1034,10 +1070,10 @@ export const getUserRouter = (
|
|
|
1034
1070
|
const device = await db.createDevice(
|
|
1035
1071
|
userID,
|
|
1036
1072
|
pending.devicePayload,
|
|
1037
|
-
|
|
1073
|
+
approvedByPasskeyID
|
|
1038
1074
|
? {
|
|
1039
1075
|
approvedByDeviceID: approverDevice.deviceID,
|
|
1040
|
-
approvedByPasskeyID
|
|
1076
|
+
approvedByPasskeyID,
|
|
1041
1077
|
}
|
|
1042
1078
|
: undefined,
|
|
1043
1079
|
);
|
|
@@ -0,0 +1,62 @@
|
|
|
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 { DevicePayload, PreKeysWS } from "@vex-chat/types";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
getCryptoProfile,
|
|
11
|
+
xConcat,
|
|
12
|
+
xConstants,
|
|
13
|
+
xEncode,
|
|
14
|
+
XUtils,
|
|
15
|
+
} from "@vex-chat/crypto";
|
|
16
|
+
|
|
17
|
+
import { spireXSignOpenAsync } from "./spireXSignOpenAsync.ts";
|
|
18
|
+
|
|
19
|
+
export async function verifyDevicePayloadPreKeySignature(
|
|
20
|
+
payload: Pick<DevicePayload, "preKey" | "preKeySignature" | "signKey">,
|
|
21
|
+
): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
return await verifySignedPreKey(
|
|
24
|
+
XUtils.decodeHex(payload.preKey),
|
|
25
|
+
XUtils.decodeHex(payload.preKeySignature),
|
|
26
|
+
payload.signKey,
|
|
27
|
+
);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function verifyPreKeyWsSignature(
|
|
34
|
+
preKey: PreKeysWS,
|
|
35
|
+
signKeyHex: string,
|
|
36
|
+
): Promise<boolean> {
|
|
37
|
+
return verifySignedPreKey(preKey.publicKey, preKey.signature, signKeyHex);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function preKeySignPayload(publicKey: Uint8Array): Uint8Array {
|
|
41
|
+
return getCryptoProfile() === "fips"
|
|
42
|
+
? xConcat(new Uint8Array([0xa1]), publicKey)
|
|
43
|
+
: xEncode(xConstants.CURVE, publicKey);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function verifySignedPreKey(
|
|
47
|
+
publicKey: Uint8Array,
|
|
48
|
+
signature: Uint8Array,
|
|
49
|
+
signKeyHex: string,
|
|
50
|
+
): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
const opened = await spireXSignOpenAsync(
|
|
53
|
+
signature,
|
|
54
|
+
XUtils.decodeHex(signKeyHex),
|
|
55
|
+
);
|
|
56
|
+
return Boolean(
|
|
57
|
+
opened && XUtils.bytesEqual(opened, preKeySignPayload(publicKey)),
|
|
58
|
+
);
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|