UNPKG

@acurast/cli

Version:

A cli to interact with the Acurast Cloud.

285 lines (281 loc) 13.1 kB
import http from 'node:http'; import { randomBytes, timingSafeEqual } from 'node:crypto'; /** Default time the local servers wait for the browser before giving up. */ const DEFAULT_TIMEOUT_MS = 5 * 60000; const SUCCESS_PAGE = (message) => `<!doctype html> <html><head><meta charset="utf-8"><title>Acurast CLI</title> <style>body{font-family:system-ui,sans-serif;background:#0b0b0b;color:#c0e700;display:flex; align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center}</style></head> <body><div><h2>Acurast CLI</h2><p>${message}</p><p>You can close this tab and return to the terminal.</p></div></body></html>`; const send = (res, status, body, contentType = 'text/html; charset=utf-8') => { res.writeHead(status, { 'content-type': contentType }); res.end(body); }; /** Constant-time string comparison that never throws on length mismatch. */ const timingEqual = (a, b) => { const ab = Buffer.from(a); const bb = Buffer.from(b); if (ab.length !== bb.length) { // Still compare against a same-length buffer to keep timing uniform. timingSafeEqual(ab, ab); return false; } return timingSafeEqual(ab, bb); }; /** * Reject requests that look cross-origin. Top-level navigations (the hub's * https→http redirect to `/callback`, and the browser opening `/sign`) carry * no `Origin` header — those are allowed, and the per-session token is the real * defense there. The `Origin`/`Sec-Fetch-Site` checks protect the fetch-based * `/payload` and `/result` endpoints from being driven by another local page. */ const assertSameOriginLocalhost = (req, port) => { // Top-level navigations — the hub's https->http redirect to `/callback` and // the browser opening `/sign` — are legitimately cross-site. Chromium reports // them as `Sec-Fetch-Site: cross-site` with `Sec-Fetch-Mode: navigate`, so we // must NOT reject on cross-site alone. The per-session token guards these. const secFetchMode = req.headers['sec-fetch-mode']; const secFetchDest = req.headers['sec-fetch-dest']; if (secFetchMode === 'navigate' || secFetchDest === 'document') { return true; } // For everything else (the fetch-based `/payload` and `/result`), block // requests driven by another site/page. const secFetchSite = req.headers['sec-fetch-site']; if (secFetchSite === 'cross-site' || secFetchSite === 'same-site') { return false; } const originHeader = req.headers['origin'] ?? req.headers['referer']; if (originHeader) { try { const origin = new URL(String(originHeader)).origin; if (origin !== `http://localhost:${port}` && origin !== `http://127.0.0.1:${port}`) { return false; } } catch { return false; } } return true; }; /** * Start an ephemeral local server that waits for the hub to redirect back with * a signed login challenge. The hub navigates the browser (top-level redirect, * not `fetch`, to avoid https→http mixed-content) to * `http://localhost:<port>/callback?address=…&signature=…&signatureType=…&token=…`. * * A high-entropy `token` is minted here and embedded in the hub URL the CLI * opens; the hub echoes it back verbatim on the redirect, and any `/callback` * whose token does not match is rejected — so a different local process or * visited page cannot forge a login callback. */ export const startLoginServer = async (options) => { const token = randomBytes(32).toString('hex'); let port = 0; let resolveCb; let rejectCb; const callbackPromise = new Promise((resolve, reject) => { resolveCb = resolve; rejectCb = reject; }); const server = http.createServer((req, res) => { if (!assertSameOriginLocalhost(req, port)) { send(res, 403, 'Forbidden', 'text/plain'); return; } const url = new URL(req.url ?? '/', 'http://localhost'); if (url.pathname !== '/callback') { send(res, 404, 'Not found', 'text/plain'); return; } if (!timingEqual(url.searchParams.get('token') ?? '', token)) { send(res, 403, SUCCESS_PAGE('Login failed: invalid request token.')); return; } const address = url.searchParams.get('address') ?? ''; const signature = url.searchParams.get('signature') ?? ''; const signatureType = url.searchParams.get('signatureType') ?? ''; if (!address || !signature) { send(res, 400, SUCCESS_PAGE('Login failed: missing parameters.')); rejectCb(new Error('Login callback missing address or signature')); return; } send(res, 200, SUCCESS_PAGE('Login successful. ✅')); resolveCb({ address, signature, signatureType }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); // NOTE: do not `unref()` here. `login` awaits `waitForCallback` with no other // active handle, so an unref'd socket would let the process exit immediately — // before the browser can reach `http://localhost:<port>/callback`. The // listening socket is what keeps the CLI alive during the wait; `close()` // (always called in `login`'s `finally`) releases it so the process can exit. port = server.address().port; const waitForCallback = (timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS) => Promise.race([ callbackPromise, new Promise((_, reject) => { const t = setTimeout(() => reject(new Error('Timed out waiting for the browser login')), timeoutMs); t.unref?.(); }), ]); return { port, token, callbackPath: '/callback', waitForCallback, close: () => server.close() }; }; /** * The HTML bridge page the CLI serves at `/sign?id=…&token=…`. It runs on the * local (http) origin, so it can `fetch` the large payload from the CLI server, * then relays it to the hub (https) `cli-sign` popup via cross-origin * `postMessage` (immune to mixed-content), and POSTs the returned signature * back to the CLI. The hub popup is told this page's origin via `?bridgeOrigin` * so its "ready" ping targets a scoped origin rather than `*`. */ const SIGN_BRIDGE_PAGE = (hubUrl, id, token) => `<!doctype html> <html><head><meta charset="utf-8"><title>Acurast CLI – Sign</title> <style>body{font-family:system-ui,sans-serif;background:#0b0b0b;color:#c0e700;display:flex; align-items:center;justify-content:center;height:100vh;margin:0}div{text-align:center} button{background:#c0e700;color:#0b0b0b;border:0;border-radius:8px;padding:12px 20px;font-size:16px;cursor:pointer}</style></head> <body><div><h2>Acurast CLI</h2><p id="msg">Preparing signature request…</p> <button id="btn" style="display:none">Open wallet to sign</button></div> <script> var HUB = ${JSON.stringify(hubUrl)}; var HUB_ORIGIN = new URL(HUB).origin; var ID = ${JSON.stringify(id)}; var TOKEN = ${JSON.stringify(token)}; var msg = document.getElementById('msg'); var btn = document.getElementById('btn'); var payload = null, summary = null, popup = null, done = false; function qs(){ return 'id='+encodeURIComponent(ID)+'&token='+encodeURIComponent(TOKEN); } function post(body){ return fetch('/result?'+qs(),{method:'POST', headers:{'content-type':'application/json'},body:JSON.stringify(body)}); } window.addEventListener('message', function(event){ if (event.origin !== HUB_ORIGIN) return; var data = event.data || {}; if (data.type === 'acurast-cli-ready' && popup) { popup.postMessage({ type:'acurast-cli-sign', payload: payload, summary: summary }, HUB_ORIGIN); } else if (data.type === 'acurast-cli-signature') { done = true; post({ signature: data.signature }).finally(function(){ msg.textContent = 'Signature received. ✅ You can close this tab.'; try { popup && popup.close(); } catch(e){} }); } else if (data.type === 'acurast-cli-error') { done = true; post({ error: String(data.error || 'unknown') }).finally(function(){ msg.textContent = 'Signing failed: ' + data.error; }); } }); function openWallet(){ var url = HUB + '/cli-sign?bridgeOrigin=' + encodeURIComponent(location.origin); popup = window.open(url, 'acurast-cli-sign', 'width=480,height=760'); if (!popup) { btn.style.display='inline-block'; msg.textContent='Please allow popups, then click below.'; return; } msg.textContent = 'Waiting for your wallet…'; } btn.addEventListener('click', openWallet); fetch('/payload?'+qs()).then(function(r){return r.json();}).then(function(j){ payload = j.payload; summary = j.summary || null; openWallet(); }).catch(function(e){ msg.textContent = 'Could not load payload: ' + e; }); </script></body></html>`; /** * Long-lived local server used by the remote signer. Holds pending sign * requests keyed by id and survives multiple sequential signatures (a deploy * signs `deploy` then, minutes later, `setEnvironments`). * * All endpoints require the per-session `token` minted here (embedded in the * bridge URL the CLI opens and forwarded by the bridge page), and reject * cross-origin requests, so another local page cannot read the payload to be * signed or POST a forged signature. */ export const startSignServer = async (hubUrl, options) => { const token = randomBytes(32).toString('hex'); const defaultTimeout = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; let port = 0; const pending = new Map(); const server = http.createServer((req, res) => { if (!assertSameOriginLocalhost(req, port)) { send(res, 403, 'Forbidden', 'text/plain'); return; } const url = new URL(req.url ?? '/', 'http://localhost'); if (!timingEqual(url.searchParams.get('token') ?? '', token)) { send(res, 403, 'Forbidden', 'text/plain'); return; } const id = url.searchParams.get('id') ?? ''; if (req.method === 'GET' && url.pathname === '/sign') { send(res, 200, SIGN_BRIDGE_PAGE(hubUrl, id, token)); return; } if (req.method === 'GET' && url.pathname === '/payload') { const entry = pending.get(id); if (!entry) { send(res, 404, JSON.stringify({ error: 'unknown id' }), 'application/json'); return; } send(res, 200, JSON.stringify({ payload: entry.payload, summary: entry.summary ?? null }), 'application/json'); return; } if (req.method === 'POST' && url.pathname === '/result') { let body = ''; req.on('data', (chunk) => { body += chunk; if (body.length > 5000000) req.destroy(); }); req.on('end', () => { const entry = pending.get(id); if (!entry) { send(res, 404, JSON.stringify({ error: 'unknown id' }), 'application/json'); return; } try { const parsed = JSON.parse(body || '{}'); if (parsed.error) { entry.reject(new Error(parsed.error)); } else if (parsed.signature) { entry.resolve(parsed.signature); } else { entry.reject(new Error('Signing response missing signature')); } } catch (e) { entry.reject(e instanceof Error ? e : new Error(String(e))); } finally { pending.delete(id); send(res, 200, JSON.stringify({ ok: true }), 'application/json'); } }); return; } send(res, 404, 'Not found', 'text/plain'); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); server.unref(); // don't let the open socket keep the CLI process alive port = server.address().port; const bridgeUrl = (id) => `http://localhost:${port}/sign?id=${encodeURIComponent(id)}&token=${encodeURIComponent(token)}`; const requestSignature = (id, payload, summary, timeoutMs = defaultTimeout) => new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(id); reject(new Error('Timed out waiting for the browser wallet signature')); }, timeoutMs); timer.unref?.(); pending.set(id, { payload, summary, resolve: (sig) => { clearTimeout(timer); resolve(sig); }, reject: (err) => { clearTimeout(timer); reject(err); }, }); }); return { port, token, bridgeUrl, requestSignature, close: () => server.close() }; }; //# sourceMappingURL=cliServer.js.map