@vex-chat/spire 2.3.3 → 2.3.4

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.
@@ -0,0 +1,510 @@
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 CSP = [
10
+ "default-src 'none'",
11
+ "base-uri 'none'",
12
+ "connect-src 'self' http://localhost:* http://127.0.0.1:*",
13
+ "form-action 'none'",
14
+ "frame-ancestors 'none'",
15
+ "img-src 'none'",
16
+ "script-src 'unsafe-inline'",
17
+ "style-src 'unsafe-inline'",
18
+ ].join("; ");
19
+
20
+ const CLI_PASSKEY_PAGE = `<!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="utf-8" />
24
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
25
+ <title>Vex Passkey</title>
26
+ <style>
27
+ :root {
28
+ color-scheme: dark;
29
+ --bg: #080b0d;
30
+ --panel: #10161a;
31
+ --panel-border: #27323a;
32
+ --text: #eef6fb;
33
+ --muted: #9fb2be;
34
+ --accent: #a8c8df;
35
+ --accent-strong: #d3ebfb;
36
+ --danger: #ff776d;
37
+ }
38
+
39
+ * {
40
+ box-sizing: border-box;
41
+ }
42
+
43
+ html,
44
+ body {
45
+ min-height: 100%;
46
+ }
47
+
48
+ body {
49
+ align-items: center;
50
+ background:
51
+ radial-gradient(circle at 50% 0%, rgba(168, 200, 223, 0.12), transparent 36rem),
52
+ var(--bg);
53
+ color: var(--text);
54
+ display: flex;
55
+ font-family:
56
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
57
+ justify-content: center;
58
+ margin: 0;
59
+ padding: 24px;
60
+ }
61
+
62
+ main {
63
+ background: rgba(16, 22, 26, 0.95);
64
+ border: 1px solid var(--panel-border);
65
+ border-radius: 8px;
66
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.42);
67
+ max-width: 460px;
68
+ padding: 28px;
69
+ width: 100%;
70
+ }
71
+
72
+ .mark {
73
+ color: var(--accent);
74
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
75
+ font-size: 34px;
76
+ height: 44px;
77
+ line-height: 44px;
78
+ margin-bottom: 18px;
79
+ width: 44px;
80
+ }
81
+
82
+ h1 {
83
+ font-size: 24px;
84
+ font-weight: 650;
85
+ letter-spacing: 0;
86
+ line-height: 1.15;
87
+ margin: 0 0 10px;
88
+ }
89
+
90
+ p {
91
+ color: var(--muted);
92
+ font-size: 15px;
93
+ line-height: 1.5;
94
+ margin: 0;
95
+ }
96
+
97
+ button {
98
+ align-items: center;
99
+ appearance: none;
100
+ background: var(--accent);
101
+ border: 0;
102
+ border-radius: 8px;
103
+ color: #071014;
104
+ cursor: pointer;
105
+ display: inline-flex;
106
+ font: inherit;
107
+ font-weight: 700;
108
+ justify-content: center;
109
+ margin-top: 24px;
110
+ min-height: 46px;
111
+ padding: 0 18px;
112
+ width: 100%;
113
+ }
114
+
115
+ button:disabled {
116
+ cursor: wait;
117
+ opacity: 0.72;
118
+ }
119
+
120
+ .status {
121
+ border-top: 1px solid var(--panel-border);
122
+ color: var(--muted);
123
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
124
+ font-size: 13px;
125
+ line-height: 1.45;
126
+ margin-top: 22px;
127
+ min-height: 20px;
128
+ padding-top: 18px;
129
+ word-break: break-word;
130
+ }
131
+
132
+ .error {
133
+ color: var(--danger);
134
+ }
135
+
136
+ .success {
137
+ color: var(--accent-strong);
138
+ }
139
+ </style>
140
+ </head>
141
+ <body>
142
+ <main>
143
+ <div class="mark">◈</div>
144
+ <h1 id="title">Continue with your passkey.</h1>
145
+ <p id="copy">Use the passkey saved in this browser or on a nearby device to finish signing in from the Vex CLI.</p>
146
+ <button id="action" type="button">Continue with passkey</button>
147
+ <div class="status" id="status">Waiting for passkey.</div>
148
+ </main>
149
+ <script>
150
+ "use strict";
151
+
152
+ var params = new URLSearchParams(window.location.hash.slice(1));
153
+ var action = document.getElementById("action");
154
+ var statusEl = document.getElementById("status");
155
+ var titleEl = document.getElementById("title");
156
+ var copyEl = document.getElementById("copy");
157
+ var mode = params.get("mode") || "recover";
158
+ var startupError = null;
159
+ var apiBase = window.location.origin;
160
+ var busy = false;
161
+
162
+ try {
163
+ apiBase = resolveTrustedApiBase(params.get("api"));
164
+ } catch (err) {
165
+ startupError = err;
166
+ }
167
+
168
+ if (mode === "register") {
169
+ titleEl.textContent = "Create a passkey for Vex.";
170
+ copyEl.textContent = "This adds the first passkey required by your new CLI account.";
171
+ action.textContent = "Create passkey";
172
+ setStatus("Ready to create passkey.");
173
+ } else {
174
+ setStatus("Ready to verify passkey.");
175
+ }
176
+
177
+ if (startupError) {
178
+ setStatus(errorMessage(startupError), "error");
179
+ action.disabled = true;
180
+ }
181
+
182
+ action.addEventListener("click", function () {
183
+ void run();
184
+ });
185
+
186
+ function setStatus(message, kind) {
187
+ statusEl.textContent = message;
188
+ statusEl.className = "status" + (kind ? " " + kind : "");
189
+ }
190
+
191
+ function setBusy(nextBusy) {
192
+ busy = nextBusy;
193
+ action.disabled = nextBusy;
194
+ }
195
+
196
+ function apiUrl(path) {
197
+ var url = new URL(path, apiBase);
198
+ url.searchParams.set("format", "json");
199
+ return url.toString();
200
+ }
201
+
202
+ function resolveTrustedApiBase(rawApi) {
203
+ if (!rawApi) {
204
+ return window.location.origin;
205
+ }
206
+ var requested;
207
+ try {
208
+ requested = new URL(rawApi, window.location.origin);
209
+ } catch (_err) {
210
+ throw new Error("Passkey link API origin is invalid.");
211
+ }
212
+ var pageOrigin = new URL(window.location.origin);
213
+ if (requested.origin === pageOrigin.origin) {
214
+ return requested.origin;
215
+ }
216
+ if (isLocalOrigin(pageOrigin) && isLocalOrigin(requested)) {
217
+ return requested.origin;
218
+ }
219
+ throw new Error("Passkey link API origin is not trusted.");
220
+ }
221
+
222
+ function isLocalOrigin(url) {
223
+ return (
224
+ (url.protocol === "http:" || url.protocol === "https:") &&
225
+ ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)
226
+ );
227
+ }
228
+
229
+ async function apiRequest(path, options) {
230
+ var requestOptions = options || {};
231
+ var headers = { Accept: "application/json" };
232
+ if (requestOptions.body !== undefined) {
233
+ headers["Content-Type"] = "application/json";
234
+ }
235
+ if (requestOptions.token) {
236
+ headers.Authorization = "Bearer " + requestOptions.token;
237
+ }
238
+ var response = await fetch(apiUrl(path), {
239
+ body:
240
+ requestOptions.body === undefined
241
+ ? undefined
242
+ : JSON.stringify(requestOptions.body),
243
+ headers: headers,
244
+ method: requestOptions.method || "POST",
245
+ });
246
+ var text = await response.text();
247
+ var payload = null;
248
+ if (text.length > 0) {
249
+ try {
250
+ payload = JSON.parse(text);
251
+ } catch (_err) {
252
+ payload = { error: text };
253
+ }
254
+ }
255
+ if (!response.ok) {
256
+ throw new Error(
257
+ payload && payload.error
258
+ ? String(payload.error)
259
+ : "Request failed with status " + response.status,
260
+ );
261
+ }
262
+ return payload;
263
+ }
264
+
265
+ async function run() {
266
+ if (busy) return;
267
+ setBusy(true);
268
+ try {
269
+ if (!window.PublicKeyCredential || !navigator.credentials) {
270
+ throw new Error("This browser does not support passkeys.");
271
+ }
272
+ if (mode === "register") {
273
+ await registerPasskey();
274
+ } else {
275
+ await recoverWithPasskey();
276
+ }
277
+ } catch (err) {
278
+ setStatus(errorMessage(err), "error");
279
+ action.textContent = mode === "register" ? "Try again" : "Retry passkey";
280
+ } finally {
281
+ setBusy(false);
282
+ }
283
+ }
284
+
285
+ function errorMessage(err) {
286
+ return err instanceof Error ? err.message : String(err);
287
+ }
288
+
289
+ async function registerPasskey() {
290
+ var token = requiredParam("token");
291
+ var userID = requiredParam("user");
292
+ var username = requiredParam("username");
293
+ var name = params.get("name") || params.get("device") || "vex-chat-cli";
294
+
295
+ setStatus("Requesting passkey challenge for @" + username + "...");
296
+ var begin = await apiRequest("/user/" + encodeURIComponent(userID) + "/passkeys/register/begin", {
297
+ body: { name: name },
298
+ token: token,
299
+ });
300
+ var credentialOptions = makeCreationOptions(begin.options);
301
+
302
+ setStatus("Waiting for browser passkey prompt...");
303
+ var credential = await navigator.credentials.create({
304
+ publicKey: credentialOptions,
305
+ });
306
+ if (!credential) {
307
+ throw new Error("No passkey was created.");
308
+ }
309
+
310
+ setStatus("Saving passkey...");
311
+ await apiRequest("/user/" + encodeURIComponent(userID) + "/passkeys/register/finish", {
312
+ body: {
313
+ name: name,
314
+ requestID: begin.requestID,
315
+ response: registrationResponseJSON(credential),
316
+ },
317
+ token: token,
318
+ });
319
+ setStatus("Passkey saved. Return to the Vex CLI.", "success");
320
+ titleEl.textContent = "Passkey saved.";
321
+ copyEl.textContent = "The CLI can finish connecting now.";
322
+ action.textContent = "Done";
323
+ }
324
+
325
+ async function recoverWithPasskey() {
326
+ var username = requiredParam("username");
327
+ var requestID = requiredParam("request");
328
+ var code = params.get("code") || "";
329
+ var codeSuffix = code ? " Code: " + code + "." : "";
330
+
331
+ setStatus("Requesting passkey challenge for @" + username + "...");
332
+ var begin = await apiRequest("/auth/passkey/begin", {
333
+ body: { username: username },
334
+ });
335
+ var requestOptions = makeRequestOptions(begin.options);
336
+
337
+ setStatus("Waiting for browser passkey prompt..." + codeSuffix);
338
+ var credential = await navigator.credentials.get({
339
+ publicKey: requestOptions,
340
+ });
341
+ if (!credential) {
342
+ throw new Error("No passkey was returned.");
343
+ }
344
+
345
+ setStatus("Verifying passkey...");
346
+ var auth = await apiRequest("/auth/passkey/finish", {
347
+ body: {
348
+ requestID: begin.requestID,
349
+ response: authenticationResponseJSON(credential),
350
+ },
351
+ });
352
+ var userID =
353
+ auth && auth.user && typeof auth.user.userID === "string"
354
+ ? auth.user.userID
355
+ : requiredParam("user");
356
+ var token = auth && typeof auth.token === "string" ? auth.token : "";
357
+ if (!token) {
358
+ throw new Error("Passkey login did not return a recovery token.");
359
+ }
360
+
361
+ setStatus("Recovering CLI device...");
362
+ await apiRequest(
363
+ "/user/" +
364
+ encodeURIComponent(userID) +
365
+ "/passkey/recover/devices/requests/" +
366
+ encodeURIComponent(requestID),
367
+ {
368
+ token: token,
369
+ },
370
+ );
371
+ setStatus("CLI device recovered. Return to the Vex CLI.", "success");
372
+ titleEl.textContent = "Device signed in.";
373
+ copyEl.textContent = "The CLI can finish connecting now.";
374
+ action.textContent = "Done";
375
+ }
376
+
377
+ function requiredParam(name) {
378
+ var value = params.get(name);
379
+ if (!value) {
380
+ throw new Error("Missing " + name + " in passkey link.");
381
+ }
382
+ return value;
383
+ }
384
+
385
+ function makeCreationOptions(options) {
386
+ var converted = Object.assign({}, options);
387
+ converted.challenge = base64UrlToArrayBuffer(options.challenge);
388
+ converted.user = Object.assign({}, options.user, {
389
+ id: base64UrlToArrayBuffer(options.user.id),
390
+ });
391
+ converted.excludeCredentials = (options.excludeCredentials || []).map(
392
+ function (credential) {
393
+ return Object.assign({}, credential, {
394
+ id: base64UrlToArrayBuffer(credential.id),
395
+ });
396
+ },
397
+ );
398
+ return converted;
399
+ }
400
+
401
+ function makeRequestOptions(options) {
402
+ var converted = Object.assign({}, options);
403
+ converted.challenge = base64UrlToArrayBuffer(options.challenge);
404
+ converted.allowCredentials = (options.allowCredentials || []).map(
405
+ function (credential) {
406
+ return Object.assign({}, credential, {
407
+ id: base64UrlToArrayBuffer(credential.id),
408
+ });
409
+ },
410
+ );
411
+ return converted;
412
+ }
413
+
414
+ function registrationResponseJSON(credential) {
415
+ var response = credential.response;
416
+ return removeUndefined({
417
+ authenticatorAttachment: credential.authenticatorAttachment,
418
+ clientExtensionResults: credential.getClientExtensionResults(),
419
+ id: credential.id,
420
+ rawId: arrayBufferToBase64Url(credential.rawId),
421
+ response: removeUndefined({
422
+ attestationObject: arrayBufferToBase64Url(response.attestationObject),
423
+ clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
424
+ transports:
425
+ typeof response.getTransports === "function"
426
+ ? response.getTransports()
427
+ : undefined,
428
+ }),
429
+ type: credential.type,
430
+ });
431
+ }
432
+
433
+ function authenticationResponseJSON(credential) {
434
+ var response = credential.response;
435
+ return removeUndefined({
436
+ authenticatorAttachment: credential.authenticatorAttachment,
437
+ clientExtensionResults: credential.getClientExtensionResults(),
438
+ id: credential.id,
439
+ rawId: arrayBufferToBase64Url(credential.rawId),
440
+ response: removeUndefined({
441
+ authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
442
+ clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
443
+ signature: arrayBufferToBase64Url(response.signature),
444
+ userHandle: response.userHandle
445
+ ? arrayBufferToBase64Url(response.userHandle)
446
+ : null,
447
+ }),
448
+ type: credential.type,
449
+ });
450
+ }
451
+
452
+ function removeUndefined(value) {
453
+ Object.keys(value).forEach(function (key) {
454
+ if (value[key] === undefined) {
455
+ delete value[key];
456
+ }
457
+ });
458
+ return value;
459
+ }
460
+
461
+ function base64UrlToArrayBuffer(value) {
462
+ var base64 = value.replace(/-/g, "+").replace(/_/g, "/");
463
+ var padding = base64.length % 4;
464
+ if (padding === 2) base64 += "==";
465
+ if (padding === 3) base64 += "=";
466
+ if (padding === 1) {
467
+ throw new Error("Invalid base64url value from server.");
468
+ }
469
+ var binary = window.atob(base64);
470
+ var bytes = new Uint8Array(binary.length);
471
+ for (var i = 0; i < binary.length; i += 1) {
472
+ bytes[i] = binary.charCodeAt(i);
473
+ }
474
+ return bytes.buffer;
475
+ }
476
+
477
+ function arrayBufferToBase64Url(buffer) {
478
+ var bytes = new Uint8Array(buffer);
479
+ var binary = "";
480
+ for (var i = 0; i < bytes.byteLength; i += 1) {
481
+ binary += String.fromCharCode(bytes[i]);
482
+ }
483
+ return window
484
+ .btoa(binary)
485
+ .replace(/\\+/g, "-")
486
+ .replace(/\\//g, "_")
487
+ .replace(/=+$/g, "");
488
+ }
489
+ </script>
490
+ </body>
491
+ </html>`;
492
+
493
+ export const getCliPasskeyPageRouter = (): express.Router => {
494
+ const router = express.Router();
495
+
496
+ router.get("/cli/passkey", (_req, res) => {
497
+ res.set({
498
+ "Cache-Control": "no-store",
499
+ "Content-Security-Policy": CSP,
500
+ "Content-Type": "text/html; charset=utf-8",
501
+ "Permissions-Policy":
502
+ "publickey-credentials-create=(self), publickey-credentials-get=(self)",
503
+ "Referrer-Policy": "no-referrer",
504
+ "X-Content-Type-Options": "nosniff",
505
+ });
506
+ res.status(200).send(CLI_PASSKEY_PAGE);
507
+ });
508
+
509
+ return router;
510
+ };
@@ -32,6 +32,7 @@ import { verifyPreKeyWsSignature } from "../utils/preKeySignature.ts";
32
32
  import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
33
33
 
34
34
  import { getAvatarRouter } from "./avatar.ts";
35
+ import { getCliPasskeyPageRouter } from "./cliPasskeyPage.ts";
35
36
  import { errorHandler } from "./errors.ts";
36
37
  import { getFileRouter } from "./file.ts";
37
38
  import { getInviteRouter } from "./invite.ts";
@@ -314,6 +315,12 @@ export const initApp = (
314
315
  );
315
316
 
316
317
  api.use(helmet());
318
+
319
+ // Browser bridge used by the CLI to run WebAuthn ceremonies at the same
320
+ // origin/RP ID as the API host. The token is carried in the URL fragment,
321
+ // so Spire never receives it when serving the page.
322
+ api.use(getCliPasskeyPageRouter());
323
+
317
324
  api.use(msgpackParser);
318
325
  api.use(checkAuth);
319
326
  api.use(createCheckDevice(db));
@@ -32,12 +32,12 @@ import jwt from "jsonwebtoken";
32
32
 
33
33
  import { JWT_EXPIRY_PASSKEY } from "../Spire.ts";
34
34
  import { getJwtSecret } from "../utils/jwtSecret.ts";
35
- import { msgpack } from "../utils/msgpack.ts";
36
35
 
37
36
  import { AppError } from "./errors.ts";
38
37
  import { authLimiter } from "./rateLimit.ts";
39
38
  import { censorUser, getParam, getUser } from "./utils.ts";
40
39
  import { buildAndroidApkKeyHashOrigins } from "./wellKnown.ts";
40
+ import { sendWireResponse } from "./wireResponse.ts";
41
41
 
42
42
  import { protect } from "./index.ts";
43
43
 
@@ -257,12 +257,10 @@ export const getPasskeyRouter = (db: Database) => {
257
257
  // shared types package doesn't take a runtime dep on
258
258
  // SimpleWebAuthn). The wire shape is identical — both
259
259
  // sides hand the JSON straight to navigator.credentials.
260
- res.send(
261
- msgpack.encode({
262
- options,
263
- requestID,
264
- }),
265
- );
260
+ sendWireResponse(req, res, {
261
+ options,
262
+ requestID,
263
+ });
266
264
  },
267
265
  );
268
266
 
@@ -379,7 +377,7 @@ export const getPasskeyRouter = (db: Database) => {
379
377
  transports,
380
378
  );
381
379
 
382
- res.send(msgpack.encode(created));
380
+ sendWireResponse(req, res, created);
383
381
  },
384
382
  );
385
383
 
@@ -391,7 +389,7 @@ export const getPasskeyRouter = (db: Database) => {
391
389
  return;
392
390
  }
393
391
  const list: Passkey[] = await db.retrievePasskeysByUser(userID);
394
- res.send(msgpack.encode(list));
392
+ sendWireResponse(req, res, list);
395
393
  });
396
394
 
397
395
  router.delete(
@@ -477,12 +475,10 @@ export const getPasskeyRouter = (db: Database) => {
477
475
  userID: user.userID,
478
476
  });
479
477
 
480
- res.send(
481
- msgpack.encode({
482
- options,
483
- requestID,
484
- }),
485
- );
478
+ sendWireResponse(req, res, {
479
+ options,
480
+ requestID,
481
+ });
486
482
  });
487
483
 
488
484
  router.post("/auth/passkey/finish", authLimiter, async (req, res) => {
@@ -596,13 +592,11 @@ export const getPasskeyRouter = (db: Database) => {
596
592
  passkeyID: passkeyRow.passkeyID,
597
593
  user: censored,
598
594
  });
599
- res.send(
600
- msgpack.encode({
601
- passkeyID: passkeyRow.passkeyID,
602
- token,
603
- user: censored,
604
- }),
605
- );
595
+ sendWireResponse(req, res, {
596
+ passkeyID: passkeyRow.passkeyID,
597
+ token,
598
+ user: censored,
599
+ });
606
600
  });
607
601
 
608
602
  return router;
@@ -8,13 +8,12 @@ import type { Database } from "../Database.ts";
8
8
 
9
9
  import express from "express";
10
10
 
11
- import { msgpack } from "../utils/msgpack.ts";
12
-
13
11
  import {
14
12
  recoverDeviceEnrollmentRequest,
15
13
  resolveDeviceEnrollmentRequest,
16
14
  } from "./user.ts";
17
15
  import { getParam, getUser } from "./utils.ts";
16
+ import { sendWireResponse } from "./wireResponse.ts";
18
17
 
19
18
  import { protectPasskey } from "./index.ts";
20
19
 
@@ -60,7 +59,7 @@ export const getPasskeyDeviceRouter = (
60
59
  return;
61
60
  }
62
61
  const list = await db.retrieveUserDeviceList([userID]);
63
- res.send(msgpack.encode(list));
62
+ sendWireResponse(req, res, list);
64
63
  },
65
64
  );
66
65
 
@@ -126,7 +125,7 @@ export const getPasskeyDeviceRouter = (
126
125
  if (result.kind === "ok") {
127
126
  notify(userID, "deviceListChanged", crypto.randomUUID());
128
127
  disconnectDevices?.(result.revokedDeviceIDs);
129
- res.send(msgpack.encode(result.device));
128
+ sendWireResponse(req, res, result.device);
130
129
  return;
131
130
  }
132
131
  res.status(result.status).send({ error: result.error });
@@ -0,0 +1,26 @@
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 express from "express";
8
+
9
+ import { msgpack } from "../utils/msgpack.ts";
10
+
11
+ /**
12
+ * Normal SDK clients expect msgpack from Spire. Browser-only helpers
13
+ * can opt into JSON with `?format=json` so they do not need to ship a
14
+ * msgpack codec just to complete a short WebAuthn bridge ceremony.
15
+ */
16
+ export function sendWireResponse(
17
+ req: express.Request,
18
+ res: express.Response,
19
+ payload: unknown,
20
+ ): void {
21
+ if (req.query["format"] === "json") {
22
+ res.json(payload);
23
+ return;
24
+ }
25
+ res.send(msgpack.encode(payload));
26
+ }