@vex-chat/spire 1.3.7 → 1.5.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 (41) hide show
  1. package/README.md +4 -0
  2. package/dist/Database.d.ts +36 -1
  3. package/dist/Database.js +109 -0
  4. package/dist/Database.js.map +1 -1
  5. package/dist/Spire.d.ts +7 -0
  6. package/dist/Spire.js +7 -0
  7. package/dist/Spire.js.map +1 -1
  8. package/dist/db/schema.d.ts +16 -0
  9. package/dist/migrations/2026-05-03_passkeys.d.ts +8 -0
  10. package/dist/migrations/2026-05-03_passkeys.js +44 -0
  11. package/dist/migrations/2026-05-03_passkeys.js.map +1 -0
  12. package/dist/server/index.d.ts +7 -0
  13. package/dist/server/index.js +63 -9
  14. package/dist/server/index.js.map +1 -1
  15. package/dist/server/passkey.d.ts +7 -0
  16. package/dist/server/passkey.js +449 -0
  17. package/dist/server/passkey.js.map +1 -0
  18. package/dist/server/passkeyDevices.d.ts +25 -0
  19. package/dist/server/passkeyDevices.js +118 -0
  20. package/dist/server/passkeyDevices.js.map +1 -0
  21. package/dist/server/user.d.ts +26 -1
  22. package/dist/server/user.js +84 -0
  23. package/dist/server/user.js.map +1 -1
  24. package/dist/server/wellKnown.d.ts +57 -0
  25. package/dist/server/wellKnown.js +122 -0
  26. package/dist/server/wellKnown.js.map +1 -0
  27. package/dist/types/express.d.ts +10 -0
  28. package/dist/types/express.js.map +1 -1
  29. package/package.json +6 -5
  30. package/src/Database.ts +134 -1
  31. package/src/Spire.ts +7 -0
  32. package/src/__tests__/passkeys.spec.ts +217 -0
  33. package/src/__tests__/wellKnown.spec.ts +113 -0
  34. package/src/db/schema.ts +18 -1
  35. package/src/migrations/2026-05-03_passkeys.ts +52 -0
  36. package/src/server/index.ts +68 -9
  37. package/src/server/passkey.ts +589 -0
  38. package/src/server/passkeyDevices.ts +154 -0
  39. package/src/server/user.ts +105 -1
  40. package/src/server/wellKnown.ts +144 -0
  41. package/src/types/express.ts +8 -0
@@ -0,0 +1,154 @@
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 { Database } from "../Database.ts";
8
+
9
+ import express from "express";
10
+
11
+ import { msgpack } from "../utils/msgpack.ts";
12
+
13
+ import { resolveDeviceEnrollmentRequest } from "./user.ts";
14
+ import { getParam, getUser } from "./utils.ts";
15
+
16
+ import { protectPasskey } from "./index.ts";
17
+
18
+ /**
19
+ * Routes that grant a passkey-authenticated session a strictly
20
+ * bounded admin/recovery surface:
21
+ *
22
+ * - `GET /user/:id/passkey/devices` — list
23
+ * - `DELETE /user/:id/passkey/devices/:deviceID` — remove
24
+ * - `POST /user/:id/passkey/devices/requests/:requestID/approve` — approve
25
+ * - `POST /user/:id/passkey/devices/requests/:requestID/reject` — reject
26
+ *
27
+ * The route family is parallel to `/user/:id/devices/...` so the
28
+ * existing device-authenticated flow stays untouched (and there's no
29
+ * confusion about which kind of credential is doing what when a
30
+ * single endpoint accepts both).
31
+ *
32
+ * Mail/server/permissions/etc. routes are intentionally NOT mirrored
33
+ * here — passkeys are an administrative credential, not a messaging
34
+ * device.
35
+ */
36
+ export const getPasskeyDeviceRouter = (
37
+ db: Database,
38
+ notify: (
39
+ userID: string,
40
+ event: string,
41
+ transmissionID: string,
42
+ data?: unknown,
43
+ deviceID?: string,
44
+ ) => void,
45
+ ) => {
46
+ const router = express.Router();
47
+
48
+ router.get(
49
+ "/user/:id/passkey/devices",
50
+ protectPasskey,
51
+ async (req, res) => {
52
+ const userDetails = getUser(req);
53
+ const userID = getParam(req, "id");
54
+ if (userDetails.userID !== userID) {
55
+ res.sendStatus(401);
56
+ return;
57
+ }
58
+ const list = await db.retrieveUserDeviceList([userID]);
59
+ res.send(msgpack.encode(list));
60
+ },
61
+ );
62
+
63
+ router.delete(
64
+ "/user/:id/passkey/devices/:deviceID",
65
+ protectPasskey,
66
+ async (req, res) => {
67
+ const userDetails = getUser(req);
68
+ const userID = getParam(req, "id");
69
+ const deviceID = getParam(req, "deviceID");
70
+ if (userDetails.userID !== userID) {
71
+ res.sendStatus(401);
72
+ return;
73
+ }
74
+ const device = await db.retrieveDevice(deviceID);
75
+ if (!device || device.owner !== userID) {
76
+ res.sendStatus(404);
77
+ return;
78
+ }
79
+ // The device-auth `DELETE /user/:id/devices/:deviceID`
80
+ // refuses to delete the user's last device (a device
81
+ // can't lock itself out). Passkeys may delete the last
82
+ // device on purpose: that's the entire recovery story —
83
+ // "I lost my phone, sign in with the passkey, wipe the
84
+ // old device, then enroll a new one with the passkey
85
+ // standing in as the approver."
86
+ await db.deleteDevice(deviceID);
87
+ // Tell whoever's online that the device-list shape
88
+ // changed; clients use this to refresh the Settings →
89
+ // Devices view in real time.
90
+ notify(userID, "deviceListChanged", crypto.randomUUID());
91
+ res.sendStatus(200);
92
+ },
93
+ );
94
+
95
+ router.post(
96
+ "/user/:id/passkey/devices/requests/:requestID/approve",
97
+ protectPasskey,
98
+ async (req, res) => {
99
+ const userDetails = getUser(req);
100
+ const userID = getParam(req, "id");
101
+ const requestID = getParam(req, "requestID");
102
+ if (userDetails.userID !== userID) {
103
+ res.sendStatus(401);
104
+ return;
105
+ }
106
+ // No second-factor signature here: the passkey JWT itself
107
+ // is fresh proof of user presence (5 min TTL, freshly
108
+ // minted from a WebAuthn ceremony with userVerification).
109
+ // Reusing it within those 5 minutes to approve an
110
+ // enrollment is fine — the equivalent guarantee that the
111
+ // device flow gets from a per-request Ed25519 sig.
112
+ const result = await resolveDeviceEnrollmentRequest({
113
+ action: "approve",
114
+ db,
115
+ notify,
116
+ requestID,
117
+ userID,
118
+ });
119
+ if (result.kind === "ok") {
120
+ res.send(msgpack.encode(result.device));
121
+ return;
122
+ }
123
+ res.status(result.status).send({ error: result.error });
124
+ },
125
+ );
126
+
127
+ router.post(
128
+ "/user/:id/passkey/devices/requests/:requestID/reject",
129
+ protectPasskey,
130
+ async (req, res) => {
131
+ const userDetails = getUser(req);
132
+ const userID = getParam(req, "id");
133
+ const requestID = getParam(req, "requestID");
134
+ if (userDetails.userID !== userID) {
135
+ res.sendStatus(401);
136
+ return;
137
+ }
138
+ const result = await resolveDeviceEnrollmentRequest({
139
+ action: "reject",
140
+ db,
141
+ notify,
142
+ requestID,
143
+ userID,
144
+ });
145
+ if (result.kind === "ok") {
146
+ res.sendStatus(200);
147
+ return;
148
+ }
149
+ res.status(result.status).send({ error: result.error });
150
+ },
151
+ );
152
+
153
+ return router;
154
+ };
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Database } from "../Database.ts";
8
- import type { DevicePayload } from "@vex-chat/types";
8
+ import type { Device, DevicePayload } from "@vex-chat/types";
9
9
 
10
10
  import express from "express";
11
11
 
@@ -101,6 +101,110 @@ export function createPendingDeviceEnrollmentRequest(
101
101
  };
102
102
  }
103
103
 
104
+ /**
105
+ * Reusable approve/reject side of a pending device-enrollment
106
+ * request. Lives here so both the device-authenticated router
107
+ * (signs an approval challenge with the approving device's
108
+ * Ed25519 key) and the passkey-authenticated router (relies on
109
+ * the freshness of the passkey JWT instead) can share the same
110
+ * state-machine + enrollment lifecycle.
111
+ *
112
+ * The device-auth caller is expected to have already verified the
113
+ * approving device's signature before invoking this helper.
114
+ */
115
+ export async function resolveDeviceEnrollmentRequest(args: {
116
+ action: "approve" | "reject";
117
+ db: Database;
118
+ notify: (
119
+ userID: string,
120
+ event: string,
121
+ transmissionID: string,
122
+ data?: unknown,
123
+ deviceID?: string,
124
+ ) => void;
125
+ requestID: string;
126
+ userID: string;
127
+ }): Promise<
128
+ | { device: Device; kind: "ok" }
129
+ | { error: string; kind: "err"; status: number }
130
+ > {
131
+ pruneDeviceEnrollmentRequests();
132
+ const pending = deviceEnrollments.get(args.requestID);
133
+ if (!pending || pending.userID !== args.userID) {
134
+ return { error: "Request not found.", kind: "err", status: 404 };
135
+ }
136
+ if (pending.status !== "pending") {
137
+ return {
138
+ error: "Request is not pending.",
139
+ kind: "err",
140
+ status: 409,
141
+ };
142
+ }
143
+ if (Date.now() - pending.createdAt > DEVICE_REQUEST_TTL_MS) {
144
+ pending.status = "expired";
145
+ pending.resolvedAt = Date.now();
146
+ pending.error = "Request expired.";
147
+ deviceEnrollments.set(args.requestID, pending);
148
+ return { error: "Request expired.", kind: "err", status: 410 };
149
+ }
150
+
151
+ if (args.action === "reject") {
152
+ pending.status = "rejected";
153
+ pending.resolvedAt = Date.now();
154
+ pending.error = "Rejected.";
155
+ deviceEnrollments.set(args.requestID, pending);
156
+ args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
157
+ requestID: args.requestID,
158
+ status: "rejected",
159
+ });
160
+ // Caller maps `kind: "ok"` without device to a 200; reuse the
161
+ // ok shape with a placeholder device since the type insists
162
+ // on one. The reject flow doesn't return a body, the caller
163
+ // discards `device`.
164
+ return {
165
+ device: {
166
+ deleted: false,
167
+ deviceID: "",
168
+ lastLogin: "",
169
+ name: "",
170
+ owner: args.userID,
171
+ signKey: "",
172
+ },
173
+ kind: "ok",
174
+ };
175
+ }
176
+
177
+ try {
178
+ const device = await args.db.createDevice(
179
+ args.userID,
180
+ pending.devicePayload,
181
+ );
182
+ pending.status = "approved";
183
+ pending.approvedDeviceID = device.deviceID;
184
+ pending.resolvedAt = Date.now();
185
+ deviceEnrollments.set(args.requestID, pending);
186
+ args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
187
+ requestID: args.requestID,
188
+ status: "approved",
189
+ });
190
+ return { device, kind: "ok" };
191
+ } catch {
192
+ pending.status = "rejected";
193
+ pending.error = "Could not create approved device.";
194
+ pending.resolvedAt = Date.now();
195
+ deviceEnrollments.set(args.requestID, pending);
196
+ args.notify(args.userID, "deviceRequest", crypto.randomUUID(), {
197
+ requestID: args.requestID,
198
+ status: "rejected",
199
+ });
200
+ return {
201
+ error: "Could not create approved device.",
202
+ kind: "err",
203
+ status: 470,
204
+ };
205
+ }
206
+ }
207
+
104
208
  function buildApprovalChallenge(
105
209
  requestID: string,
106
210
  signKey: string,
@@ -0,0 +1,144 @@
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 express from "express";
8
+
9
+ const HEX_FINGERPRINT = /^[0-9a-fA-F]{2}(?::?[0-9a-fA-F]{2}){31}$/;
10
+
11
+ /**
12
+ * Build the Apple App Site Association body, or `null` when the
13
+ * server isn't configured to advertise any iOS apps.
14
+ */
15
+ export function buildAppleAppSiteAssociation(): null | {
16
+ webcredentials: { apps: string[] };
17
+ } {
18
+ const apps = parseList(process.env["SPIRE_PASSKEY_IOS_APP_IDS"]);
19
+ if (apps.length === 0) {
20
+ return null;
21
+ }
22
+ return { webcredentials: { apps } };
23
+ }
24
+
25
+ /**
26
+ * Build the Android Digital Asset Links body, or `null` when the
27
+ * server isn't configured to advertise an Android app.
28
+ */
29
+ export function buildAssetLinks():
30
+ | null
31
+ | {
32
+ relation: string[];
33
+ target: {
34
+ namespace: "android_app";
35
+ package_name: string;
36
+ sha256_cert_fingerprints: string[];
37
+ };
38
+ }[] {
39
+ const packageName = process.env["SPIRE_PASSKEY_ANDROID_PACKAGE"]?.trim();
40
+ const fingerprintsRaw = parseList(
41
+ process.env["SPIRE_PASSKEY_ANDROID_FINGERPRINTS"],
42
+ );
43
+ if (!packageName || fingerprintsRaw.length === 0) {
44
+ return null;
45
+ }
46
+ const fingerprints: string[] = [];
47
+ for (const raw of fingerprintsRaw) {
48
+ const norm = normalizeFingerprint(raw);
49
+ if (norm != null) {
50
+ fingerprints.push(norm);
51
+ }
52
+ }
53
+ if (fingerprints.length === 0) {
54
+ return null;
55
+ }
56
+ return [
57
+ {
58
+ relation: [
59
+ "delegate_permission/common.get_login_creds",
60
+ "delegate_permission/common.handle_all_urls",
61
+ ],
62
+ target: {
63
+ namespace: "android_app",
64
+ package_name: packageName,
65
+ sha256_cert_fingerprints: fingerprints,
66
+ },
67
+ },
68
+ ];
69
+ }
70
+
71
+ /**
72
+ * Normalize a SHA-256 fingerprint to upper-case `AA:BB:...` form.
73
+ *
74
+ * Accepts hex with or without colon separators. Returns `null` for
75
+ * anything that isn't a valid 32-byte SHA-256 hex value.
76
+ */
77
+ export function normalizeFingerprint(raw: string): null | string {
78
+ if (!HEX_FINGERPRINT.test(raw)) {
79
+ return null;
80
+ }
81
+ const hex = raw.replace(/:/g, "").toUpperCase();
82
+ const pairs = hex.match(/.{2}/g);
83
+ return pairs ? pairs.join(":") : null;
84
+ }
85
+
86
+ function parseList(envValue: string | undefined): string[] {
87
+ if (envValue == null) {
88
+ return [];
89
+ }
90
+ return envValue
91
+ .split(",")
92
+ .map((s) => s.trim())
93
+ .filter((s) => s.length > 0);
94
+ }
95
+
96
+ /**
97
+ * Serves the WebAuthn well-known association files Apple and Google
98
+ * fetch to verify the app ↔ domain link before letting their
99
+ * Credential Manager run a passkey ceremony.
100
+ *
101
+ * Both files are served at their canonical paths and 404 when the
102
+ * corresponding env vars are not set, so a non-passkey deployment is
103
+ * indistinguishable from one that simply hasn't published an app yet.
104
+ *
105
+ * Env:
106
+ * - `SPIRE_PASSKEY_IOS_APP_IDS` — comma-separated `TEAMID.bundle.id`
107
+ * for the AASA `webcredentials.apps` array (e.g.
108
+ * `ABCDE12345.chat.vex.mobile`). Required for iOS.
109
+ * - `SPIRE_PASSKEY_ANDROID_PACKAGE` — Android package name. Required
110
+ * for Android together with `SPIRE_PASSKEY_ANDROID_FINGERPRINTS`.
111
+ * - `SPIRE_PASSKEY_ANDROID_FINGERPRINTS` — comma-separated SHA-256
112
+ * fingerprints (with or without colons) of the certs that sign the
113
+ * Android app.
114
+ *
115
+ * Mount this router BEFORE the global rate limiter so periodic
116
+ * platform fetches are never 429'd.
117
+ */
118
+ export const getWellKnownRouter = (): express.Router => {
119
+ const router = express.Router();
120
+
121
+ router.get("/.well-known/apple-app-site-association", (_req, res) => {
122
+ const body = buildAppleAppSiteAssociation();
123
+ if (!body) {
124
+ res.sendStatus(404);
125
+ return;
126
+ }
127
+ res.setHeader("Content-Type", "application/json");
128
+ res.setHeader("Cache-Control", "public, max-age=300");
129
+ res.status(200).end(JSON.stringify(body));
130
+ });
131
+
132
+ router.get("/.well-known/assetlinks.json", (_req, res) => {
133
+ const body = buildAssetLinks();
134
+ if (!body) {
135
+ res.sendStatus(404);
136
+ return;
137
+ }
138
+ res.setHeader("Content-Type", "application/json");
139
+ res.setHeader("Cache-Control", "public, max-age=300");
140
+ res.status(200).end(JSON.stringify(body));
141
+ });
142
+
143
+ return router;
144
+ };
@@ -21,6 +21,14 @@ declare global {
21
21
  bearerToken?: string;
22
22
  device?: Device;
23
23
  exp?: number;
24
+ /**
25
+ * Set by `checkPasskey` middleware when the bearer token is
26
+ * a passkey-scoped JWT. The presence of `req.passkey`
27
+ * (without `req.device`) marks an admin-only request that
28
+ * may list/delete devices and approve enrollments, but
29
+ * cannot send mail or do anything device-specific.
30
+ */
31
+ passkey?: { passkeyID: string };
24
32
  user?: User;
25
33
  }
26
34
  }