@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.
@@ -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
- const message = await spireXSignOpenAsync(
746
- otk.signature,
747
- XUtils.decodeHex(device.signKey),
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);
@@ -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?: { deferOwnerNotification?: boolean },
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
- req.passkey?.passkeyID,
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
- req.passkey
1073
+ approvedByPasskeyID
1038
1074
  ? {
1039
1075
  approvedByDeviceID: approverDevice.deviceID,
1040
- approvedByPasskeyID: req.passkey.passkeyID,
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
+ }