@vex-chat/spire 2.3.2 → 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.
- package/README.md +1 -1
- package/dist/Spire.js +0 -7
- package/dist/Spire.js.map +1 -1
- package/dist/server/cliPasskeyPage.d.ts +7 -0
- package/dist/server/cliPasskeyPage.js +504 -0
- package/dist/server/cliPasskeyPage.js.map +1 -0
- package/dist/server/index.js +5 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/passkey.js +9 -9
- package/dist/server/passkey.js.map +1 -1
- package/dist/server/passkeyDevices.js +3 -3
- package/dist/server/passkeyDevices.js.map +1 -1
- package/dist/server/user.js +0 -5
- package/dist/server/user.js.map +1 -1
- package/dist/server/wireResponse.d.ts +12 -0
- package/dist/server/wireResponse.js +19 -0
- package/dist/server/wireResponse.js.map +1 -0
- package/package.json +5 -5
- package/src/Spire.ts +0 -11
- package/src/__tests__/cliPasskeyPage.spec.ts +53 -0
- package/src/__tests__/wireResponse.spec.ts +46 -0
- package/src/server/cliPasskeyPage.ts +510 -0
- package/src/server/index.ts +7 -0
- package/src/server/passkey.ts +16 -22
- package/src/server/passkeyDevices.ts +3 -4
- package/src/server/user.ts +0 -5
- package/src/server/wireResponse.ts +26 -0
|
@@ -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
|
+
};
|
package/src/server/index.ts
CHANGED
|
@@ -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));
|
package/src/server/passkey.ts
CHANGED
|
@@ -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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
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
|
|
128
|
+
sendWireResponse(req, res, result.device);
|
|
130
129
|
return;
|
|
131
130
|
}
|
|
132
131
|
res.status(result.status).send({ error: result.error });
|
package/src/server/user.ts
CHANGED
|
@@ -25,7 +25,6 @@ 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";
|
|
29
28
|
import { spireXSignOpenAsync } from "../utils/spireXSignOpenAsync.ts";
|
|
30
29
|
|
|
31
30
|
import { AppError } from "./errors.ts";
|
|
@@ -895,10 +894,6 @@ export const getUserRouter = (
|
|
|
895
894
|
}
|
|
896
895
|
|
|
897
896
|
if (tokenValidator(stringify(token), TokenScopes.Device)) {
|
|
898
|
-
if (!(await verifyDevicePayloadPreKeySignature(deviceData))) {
|
|
899
|
-
res.status(400).send({ error: "Prekey signature invalid." });
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
897
|
const userDevices = await db.retrieveUserDeviceList([
|
|
903
898
|
userDetails.userID,
|
|
904
899
|
]);
|
|
@@ -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
|
+
}
|