Skip to content

Commit

Permalink
fix: SECURITY: use a private on-disk webkey for trusted auth
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelfig committed Sep 13, 2020
1 parent 96136cc commit f769d95
Show file tree
Hide file tree
Showing 9 changed files with 237 additions and 19 deletions.
21 changes: 18 additions & 3 deletions packages/agoric-cli/lib/deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { makePromiseKit } from '@agoric/promise-kit';
import bundleSource from '@agoric/bundle-source';
import path from 'path';

// note: CapTP has it's own HandledPromise instantiation, and the contract
import { getWebkey } from './open';

// note: CapTP has its own HandledPromise instantiation, and the contract
// must use the same one that CapTP uses. We achieve this by not bundling
// captp, and doing a (non-isolated) dynamic import of the deploy script
// below, so everything uses the same module table. The eventual-send that
Expand Down Expand Up @@ -58,8 +60,21 @@ export default async function deployMain(progname, rawArgs, powers, opts) {
() => process.stdout.write(progressDot),
1000,
);
const retryWebsocket = () => {
const ws = makeWebSocket(wsurl, { origin: 'http://127.0.0.1' });
const retryWebsocket = async () => {
let wskeyurl;
try {
const webkey = await getWebkey(opts.hostport);
wskeyurl = `${wsurl}?webkey=${encodeURIComponent(webkey)}`;
} catch (e) {
if (e.code === 'ECONNREFUSED' && !connected) {
// Retry in a little bit.
setTimeout(retryWebsocket, RETRY_DELAY_MS);
} else {
console.error(`Trying to fetch webkey:`, e);
}
return;
}
const ws = makeWebSocket(wskeyurl, { origin: 'http://127.0.0.1' });
ws.on('open', async () => {
connected = true;
try {
Expand Down
19 changes: 19 additions & 0 deletions packages/agoric-cli/lib/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import initMain from './init';
import installMain from './install';
import setDefaultsMain from './set-defaults';
import startMain from './start';
import walletMain from './open';

const DEFAULT_DAPP_TEMPLATE = 'dapp-encouragement';
const DEFAULT_DAPP_URL_BASE = 'git://github.com/Agoric/';
Expand Down Expand Up @@ -60,6 +61,24 @@ const main = async (progname, rawArgs, powers) => {
return subMain(cosmosMain, ['cosmos', ...command], opts);
});

program
.command('open')
.description('launch the Agoric UI')
.option(
'--hostport <host:port>',
'host and port to connect to VM',
'127.0.0.1:8000',
)
.option(
'--repl <both | only | none>',
'whether to show the Read-eval-print loop',
'none',
)
.action(async cmd => {
const opts = { ...program.opts(), ...cmd.opts() };
return subMain(walletMain, ['wallet'], opts);
});

program
.command('init <project>')
.description('create a new Dapp directory named <project>')
Expand Down
86 changes: 86 additions & 0 deletions packages/agoric-cli/lib/open.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import http from 'http';
import fs from 'fs';
import opener from 'opener';

const RETRY_DELAY_MS = 1000;

export async function getWebkey(hostport) {
const basedir = await new Promise((resolve, reject) => {
const req = http.get(`http://${hostport}/ag-solo-basedir`, res => {
let buf = '';
res.on('data', chunk => {
buf += chunk;
});
res.on('close', () => resolve(buf));
});
req.on('error', e => {
reject(e);
});
});

const privateWebkey = fs.readFileSync(
`${basedir}/private-webkey.txt`,
'utf-8',
);

return privateWebkey;
}

export default async function walletMain(progname, rawArgs, powers, opts) {
const { anylogger } = powers;
const console = anylogger('agoric:wallet');

let suffix;
switch (opts.repl) {
case 'both':
suffix = '';
break;
case 'none':
suffix = '/wallet';
break;
case 'only':
suffix = '?w=0';
break;
default:
throw Error(`--repl must be one of 'both', 'none', or 'only'`);
}

process.stderr.write(`Launching wallet...`);
const progressDot = '.';
const progressTimer = setInterval(
() => process.stderr.write(progressDot),
1000,
);
const walletWebkey = await new Promise((resolve, reject) => {
const retryGetWebkey = async () => {
try {
const webkey = await getWebkey(opts.hostport);
resolve(webkey);
} catch (e) {
if (e.code === 'ECONNREFUSED') {
// Retry in a little bit.
setTimeout(retryGetWebkey, RETRY_DELAY_MS);
} else {
console.error(`Trying to fetch webkey:`, e);
reject(e);
}
}
};
retryGetWebkey();
});

clearInterval(progressTimer);
process.stderr.write('\n');

// Write out the URL and launch the web browser.
const walletUrl = `http://${
opts.hostport
}${suffix}#webkey=${encodeURIComponent(walletWebkey)}`;

process.stdout.write(`${walletUrl}\n`);
const browser = opener(walletUrl);
browser.unref();
process.stdout.unref();
process.stderr.unref();
process.stdin.unref();
}
1 change: 1 addition & 0 deletions packages/agoric-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"commander": "^5.0.0",
"deterministic-json": "^1.0.5",
"esm": "^3.2.25",
"opener": "^1.5.2",
"ws": "^7.2.0"
},
"keywords": [],
Expand Down
38 changes: 31 additions & 7 deletions packages/cosmic-swingset/lib/ag-solo/html/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,35 @@ const RECONNECT_BACKOFF_SECONDS = 3;
const resetFns = [];
let inpBackground;

if (!window.location.hash) {
// This is friendly advice to the user who doesn't know.
// eslint-disable-next-line no-alert
window.alert(
`\
You must open the Agoric Wallet+REPL with the
agoric open --repl=both
command line executable.
`,
);
window.location =
'https://agoric.com/documentation/getting-started/agoric-cli-guide.html#agoric-open';
}

function run() {
const disableFns = []; // Functions to run when the input should be disabled.
resetFns.push(() => (document.querySelector('#history').innerHTML = ''));

const loc = window.location;

const urlParams = `?${loc.hash.slice(1)}`;
// TODO: Maybe clear out the hash for privacy.
// loc.hash = 'webkey=*redacted*';

let nextHistNum = 0;
let inputHistoryNum = 0;

async function call(req) {
const res = await fetch('/private/repl', {
const res = await fetch(`/private/repl${urlParams}`, {
method: 'POST',
body: JSON.stringify(req),
headers: { 'Content-Type': 'application/json' },
Expand All @@ -24,9 +44,8 @@ function run() {
throw new Error(`server error: ${JSON.stringify(j.rej)}`);
}

const loc = window.location;
const protocol = loc.protocol.replace(/^http/, 'ws');
const socketEndpoint = `${protocol}//${loc.host}/private/repl`;
const socketEndpoint = `${protocol}//${loc.host}/private/repl${urlParams}`;
const ws = new WebSocket(socketEndpoint);

ws.addEventListener('error', ev => {
Expand Down Expand Up @@ -243,9 +262,11 @@ function run() {
resetFns.push(() =>
document.getElementById('go').removeAttribute('disabled'),
);

return urlParams;
}

run();
const urlParams = run();

// Display version information, if possible.
const fetches = [];
Expand Down Expand Up @@ -274,14 +295,17 @@ const fpj = fetch('/package.json')
fetches.push(fpj);

// an optional `w=0` GET argument will suppress showing the wallet
if (new URLSearchParams(window.location.search).get('w') !== '0') {
fetch('wallet/')
if (
window.location.hash &&
new URLSearchParams(window.location.search).get('w') !== '0'
) {
fetch(`wallet/${urlParams}`)
.then(resp => {
if (resp.status < 200 || resp.status >= 300) {
throw Error(`status ${resp.status}`);
}
walletFrame.style.display = 'block';
walletFrame.src = 'wallet/';
walletFrame.src = `wallet/${window.location.hash}`;
})
.catch(e => {
console.log('Cannot fetch wallet/', e);
Expand Down
2 changes: 1 addition & 1 deletion packages/cosmic-swingset/lib/ag-solo/init-basedir.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function initBasedir(

const here = __dirname;
try {
fs.mkdirSync(basedir);
fs.mkdirSync(basedir, 0o700);
} catch (e) {
if (!fs.existsSync(path.join(basedir, 'ag-cosmos-helper-address'))) {
log.error(
Expand Down
65 changes: 58 additions & 7 deletions packages/cosmic-swingset/lib/ag-solo/web.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createConnection } from 'net';
import express from 'express';
import WebSocket from 'ws';
import fs from 'fs';
import crypto from 'crypto';

import anylogger from 'anylogger';

Expand All @@ -23,16 +24,44 @@ const send = (ws, msg) => {
}
};

// Taken from https://github.com/marcsAtSkyhunter/Capper/blob/9d20b92119f91da5201a10a0834416bd449c4706/caplib.js#L80
export function unique() {
const chars =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_';
let ans = '';
const buf = crypto.randomBytes(25);
for (let i = 0; i < buf.length; i++) {
const index = buf[i] % chars.length;
ans += chars[index];
}
// while (ans.length < 30) {
// var nextI = Math.floor(Math.random()*10000) % chars.length;
// ans += chars[nextI];
// }
return ans;
}

export async function makeHTTPListener(basedir, port, host, rawInboundCommand) {
// Ensure we're protected with a unique webkey for this basedir.
fs.chmodSync(basedir, 0o700);
const privateWebkeyFile = path.join(basedir, 'private-webkey.txt');
if (!fs.existsSync(privateWebkeyFile)) {
// Create the unique string for this basedir.
fs.writeFileSync(privateWebkeyFile, unique(), { mode: 0o600 });
}

// Enrich the inbound command with some metadata.
const inboundCommand = (
body,
{ channelID, dispatcher, url, headers: { origin } = {} } = {},
id = undefined,
) => {
// Strip away the query params, as the webkey is there.
const qmark = url.indexOf('?');
const shortUrl = qmark < 0 ? url : url.slice(0, qmark);
const obj = {
...body,
meta: { channelID, dispatcher, origin, url, date: Date.now() },
meta: { channelID, dispatcher, origin, url: shortUrl, date: Date.now() },
};
return rawInboundCommand(obj).catch(err => {
const idpfx = id ? `${id} ` : '';
Expand Down Expand Up @@ -75,15 +104,30 @@ export async function makeHTTPListener(basedir, port, host, rawInboundCommand) {
log(`Serving static files from ${htmldir}`);
app.use(express.static(htmldir));

const validateOrigin = req => {
const validateOriginAndWebkey = req => {
const { origin } = req.headers;
const id = `${req.socket.remoteAddress}:${req.socket.remotePort}:`;

if (!req.url.startsWith('/private/')) {
// Allow any origin that's not marked private.
// Allow any origin that's not marked private, without a webkey.
return true;
}

// Validate the private webkey.
const privateWebkey = fs.readFileSync(privateWebkeyFile, 'utf-8');
const reqWebkey = new URL(`http://localhost${req.url}`).searchParams.get(
'webkey',
);
if (reqWebkey !== privateWebkey) {
log.error(
id,
`Invalid webkey ${JSON.stringify(
reqWebkey,
)}; try running "agoric open"`,
);
return false;
}

if (!origin) {
log.error(id, `Missing origin header`);
return false;
Expand All @@ -93,7 +137,7 @@ export async function makeHTTPListener(basedir, port, host, rawInboundCommand) {
hostname.match(/^(localhost|127\.0\.0\.1)$/);

if (['chrome-extension:', 'moz-extension:'].includes(url.protocol)) {
// Extensions such as metamask can access the wallet.
// Extensions such as metamask are local and can access the wallet.
return true;
}

Expand All @@ -109,10 +153,17 @@ export async function makeHTTPListener(basedir, port, host, rawInboundCommand) {
return true;
};

// Allow people to see where this installation is.
app.get('/ag-solo-basedir', (req, res) => {
res.contentType('text/plain');
res.write(basedir);
res.end();
});

// accept POST messages to arbitrary endpoints
app.post('*', (req, res) => {
if (!validateOrigin(req)) {
res.json({ ok: false, rej: 'Unauthorized Origin' });
if (!validateOriginAndWebkey(req)) {
res.json({ ok: false, rej: 'Unauthorized' });
return;
}

Expand All @@ -130,7 +181,7 @@ export async function makeHTTPListener(basedir, port, host, rawInboundCommand) {
// GETs (which should return index.html) and WebSocket requests.
const wss = new WebSocket.Server({ noServer: true });
server.on('upgrade', (req, socket, head) => {
if (!validateOrigin(req)) {
if (!validateOriginAndWebkey(req)) {
socket.destroy();
return;
}
Expand Down
Loading

0 comments on commit f769d95

Please sign in to comment.