Skip to content

Commit f769d95

Browse files
committed
fix: SECURITY: use a private on-disk webkey for trusted auth
1 parent 96136cc commit f769d95

File tree

9 files changed

+237
-19
lines changed

9 files changed

+237
-19
lines changed

packages/agoric-cli/lib/deploy.js

+18-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { makePromiseKit } from '@agoric/promise-kit';
44
import bundleSource from '@agoric/bundle-source';
55
import path from 'path';
66

7-
// note: CapTP has it's own HandledPromise instantiation, and the contract
7+
import { getWebkey } from './open';
8+
9+
// note: CapTP has its own HandledPromise instantiation, and the contract
810
// must use the same one that CapTP uses. We achieve this by not bundling
911
// captp, and doing a (non-isolated) dynamic import of the deploy script
1012
// below, so everything uses the same module table. The eventual-send that
@@ -58,8 +60,21 @@ export default async function deployMain(progname, rawArgs, powers, opts) {
5860
() => process.stdout.write(progressDot),
5961
1000,
6062
);
61-
const retryWebsocket = () => {
62-
const ws = makeWebSocket(wsurl, { origin: 'http://127.0.0.1' });
63+
const retryWebsocket = async () => {
64+
let wskeyurl;
65+
try {
66+
const webkey = await getWebkey(opts.hostport);
67+
wskeyurl = `${wsurl}?webkey=${encodeURIComponent(webkey)}`;
68+
} catch (e) {
69+
if (e.code === 'ECONNREFUSED' && !connected) {
70+
// Retry in a little bit.
71+
setTimeout(retryWebsocket, RETRY_DELAY_MS);
72+
} else {
73+
console.error(`Trying to fetch webkey:`, e);
74+
}
75+
return;
76+
}
77+
const ws = makeWebSocket(wskeyurl, { origin: 'http://127.0.0.1' });
6378
ws.on('open', async () => {
6479
connected = true;
6580
try {

packages/agoric-cli/lib/main.js

+19
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import initMain from './init';
66
import installMain from './install';
77
import setDefaultsMain from './set-defaults';
88
import startMain from './start';
9+
import walletMain from './open';
910

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

64+
program
65+
.command('open')
66+
.description('launch the Agoric UI')
67+
.option(
68+
'--hostport <host:port>',
69+
'host and port to connect to VM',
70+
'127.0.0.1:8000',
71+
)
72+
.option(
73+
'--repl <both | only | none>',
74+
'whether to show the Read-eval-print loop',
75+
'none',
76+
)
77+
.action(async cmd => {
78+
const opts = { ...program.opts(), ...cmd.opts() };
79+
return subMain(walletMain, ['wallet'], opts);
80+
});
81+
6382
program
6483
.command('init <project>')
6584
.description('create a new Dapp directory named <project>')

packages/agoric-cli/lib/open.js

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import http from 'http';
2+
import fs from 'fs';
3+
import opener from 'opener';
4+
5+
const RETRY_DELAY_MS = 1000;
6+
7+
export async function getWebkey(hostport) {
8+
const basedir = await new Promise((resolve, reject) => {
9+
const req = http.get(`http://${hostport}/ag-solo-basedir`, res => {
10+
let buf = '';
11+
res.on('data', chunk => {
12+
buf += chunk;
13+
});
14+
res.on('close', () => resolve(buf));
15+
});
16+
req.on('error', e => {
17+
reject(e);
18+
});
19+
});
20+
21+
const privateWebkey = fs.readFileSync(
22+
`${basedir}/private-webkey.txt`,
23+
'utf-8',
24+
);
25+
26+
return privateWebkey;
27+
}
28+
29+
export default async function walletMain(progname, rawArgs, powers, opts) {
30+
const { anylogger } = powers;
31+
const console = anylogger('agoric:wallet');
32+
33+
let suffix;
34+
switch (opts.repl) {
35+
case 'both':
36+
suffix = '';
37+
break;
38+
case 'none':
39+
suffix = '/wallet';
40+
break;
41+
case 'only':
42+
suffix = '?w=0';
43+
break;
44+
default:
45+
throw Error(`--repl must be one of 'both', 'none', or 'only'`);
46+
}
47+
48+
process.stderr.write(`Launching wallet...`);
49+
const progressDot = '.';
50+
const progressTimer = setInterval(
51+
() => process.stderr.write(progressDot),
52+
1000,
53+
);
54+
const walletWebkey = await new Promise((resolve, reject) => {
55+
const retryGetWebkey = async () => {
56+
try {
57+
const webkey = await getWebkey(opts.hostport);
58+
resolve(webkey);
59+
} catch (e) {
60+
if (e.code === 'ECONNREFUSED') {
61+
// Retry in a little bit.
62+
setTimeout(retryGetWebkey, RETRY_DELAY_MS);
63+
} else {
64+
console.error(`Trying to fetch webkey:`, e);
65+
reject(e);
66+
}
67+
}
68+
};
69+
retryGetWebkey();
70+
});
71+
72+
clearInterval(progressTimer);
73+
process.stderr.write('\n');
74+
75+
// Write out the URL and launch the web browser.
76+
const walletUrl = `http://${
77+
opts.hostport
78+
}${suffix}#webkey=${encodeURIComponent(walletWebkey)}`;
79+
80+
process.stdout.write(`${walletUrl}\n`);
81+
const browser = opener(walletUrl);
82+
browser.unref();
83+
process.stdout.unref();
84+
process.stderr.unref();
85+
process.stdin.unref();
86+
}

packages/agoric-cli/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"commander": "^5.0.0",
3434
"deterministic-json": "^1.0.5",
3535
"esm": "^3.2.25",
36+
"opener": "^1.5.2",
3637
"ws": "^7.2.0"
3738
},
3839
"keywords": [],

packages/cosmic-swingset/lib/ag-solo/html/main.js

+31-7
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,35 @@ const RECONNECT_BACKOFF_SECONDS = 3;
44
const resetFns = [];
55
let inpBackground;
66

7+
if (!window.location.hash) {
8+
// This is friendly advice to the user who doesn't know.
9+
// eslint-disable-next-line no-alert
10+
window.alert(
11+
`\
12+
You must open the Agoric Wallet+REPL with the
13+
agoric open --repl=both
14+
command line executable.
15+
`,
16+
);
17+
window.location =
18+
'https://agoric.com/documentation/getting-started/agoric-cli-guide.html#agoric-open';
19+
}
20+
721
function run() {
822
const disableFns = []; // Functions to run when the input should be disabled.
923
resetFns.push(() => (document.querySelector('#history').innerHTML = ''));
1024

25+
const loc = window.location;
26+
27+
const urlParams = `?${loc.hash.slice(1)}`;
28+
// TODO: Maybe clear out the hash for privacy.
29+
// loc.hash = 'webkey=*redacted*';
30+
1131
let nextHistNum = 0;
1232
let inputHistoryNum = 0;
1333

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

27-
const loc = window.location;
2847
const protocol = loc.protocol.replace(/^http/, 'ws');
29-
const socketEndpoint = `${protocol}//${loc.host}/private/repl`;
48+
const socketEndpoint = `${protocol}//${loc.host}/private/repl${urlParams}`;
3049
const ws = new WebSocket(socketEndpoint);
3150

3251
ws.addEventListener('error', ev => {
@@ -243,9 +262,11 @@ function run() {
243262
resetFns.push(() =>
244263
document.getElementById('go').removeAttribute('disabled'),
245264
);
265+
266+
return urlParams;
246267
}
247268

248-
run();
269+
const urlParams = run();
249270

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

276297
// an optional `w=0` GET argument will suppress showing the wallet
277-
if (new URLSearchParams(window.location.search).get('w') !== '0') {
278-
fetch('wallet/')
298+
if (
299+
window.location.hash &&
300+
new URLSearchParams(window.location.search).get('w') !== '0'
301+
) {
302+
fetch(`wallet/${urlParams}`)
279303
.then(resp => {
280304
if (resp.status < 200 || resp.status >= 300) {
281305
throw Error(`status ${resp.status}`);
282306
}
283307
walletFrame.style.display = 'block';
284-
walletFrame.src = 'wallet/';
308+
walletFrame.src = `wallet/${window.location.hash}`;
285309
})
286310
.catch(e => {
287311
console.log('Cannot fetch wallet/', e);

packages/cosmic-swingset/lib/ag-solo/init-basedir.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export default function initBasedir(
2121

2222
const here = __dirname;
2323
try {
24-
fs.mkdirSync(basedir);
24+
fs.mkdirSync(basedir, 0o700);
2525
} catch (e) {
2626
if (!fs.existsSync(path.join(basedir, 'ag-cosmos-helper-address'))) {
2727
log.error(

packages/cosmic-swingset/lib/ag-solo/web.js

+58-7
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createConnection } from 'net';
55
import express from 'express';
66
import WebSocket from 'ws';
77
import fs from 'fs';
8+
import crypto from 'crypto';
89

910
import anylogger from 'anylogger';
1011

@@ -23,16 +24,44 @@ const send = (ws, msg) => {
2324
}
2425
};
2526

27+
// Taken from https://github.com/marcsAtSkyhunter/Capper/blob/9d20b92119f91da5201a10a0834416bd449c4706/caplib.js#L80
28+
export function unique() {
29+
const chars =
30+
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_';
31+
let ans = '';
32+
const buf = crypto.randomBytes(25);
33+
for (let i = 0; i < buf.length; i++) {
34+
const index = buf[i] % chars.length;
35+
ans += chars[index];
36+
}
37+
// while (ans.length < 30) {
38+
// var nextI = Math.floor(Math.random()*10000) % chars.length;
39+
// ans += chars[nextI];
40+
// }
41+
return ans;
42+
}
43+
2644
export async function makeHTTPListener(basedir, port, host, rawInboundCommand) {
45+
// Ensure we're protected with a unique webkey for this basedir.
46+
fs.chmodSync(basedir, 0o700);
47+
const privateWebkeyFile = path.join(basedir, 'private-webkey.txt');
48+
if (!fs.existsSync(privateWebkeyFile)) {
49+
// Create the unique string for this basedir.
50+
fs.writeFileSync(privateWebkeyFile, unique(), { mode: 0o600 });
51+
}
52+
2753
// Enrich the inbound command with some metadata.
2854
const inboundCommand = (
2955
body,
3056
{ channelID, dispatcher, url, headers: { origin } = {} } = {},
3157
id = undefined,
3258
) => {
59+
// Strip away the query params, as the webkey is there.
60+
const qmark = url.indexOf('?');
61+
const shortUrl = qmark < 0 ? url : url.slice(0, qmark);
3362
const obj = {
3463
...body,
35-
meta: { channelID, dispatcher, origin, url, date: Date.now() },
64+
meta: { channelID, dispatcher, origin, url: shortUrl, date: Date.now() },
3665
};
3766
return rawInboundCommand(obj).catch(err => {
3867
const idpfx = id ? `${id} ` : '';
@@ -75,15 +104,30 @@ export async function makeHTTPListener(basedir, port, host, rawInboundCommand) {
75104
log(`Serving static files from ${htmldir}`);
76105
app.use(express.static(htmldir));
77106

78-
const validateOrigin = req => {
107+
const validateOriginAndWebkey = req => {
79108
const { origin } = req.headers;
80109
const id = `${req.socket.remoteAddress}:${req.socket.remotePort}:`;
81110

82111
if (!req.url.startsWith('/private/')) {
83-
// Allow any origin that's not marked private.
112+
// Allow any origin that's not marked private, without a webkey.
84113
return true;
85114
}
86115

116+
// Validate the private webkey.
117+
const privateWebkey = fs.readFileSync(privateWebkeyFile, 'utf-8');
118+
const reqWebkey = new URL(`http://localhost${req.url}`).searchParams.get(
119+
'webkey',
120+
);
121+
if (reqWebkey !== privateWebkey) {
122+
log.error(
123+
id,
124+
`Invalid webkey ${JSON.stringify(
125+
reqWebkey,
126+
)}; try running "agoric open"`,
127+
);
128+
return false;
129+
}
130+
87131
if (!origin) {
88132
log.error(id, `Missing origin header`);
89133
return false;
@@ -93,7 +137,7 @@ export async function makeHTTPListener(basedir, port, host, rawInboundCommand) {
93137
hostname.match(/^(localhost|127\.0\.0\.1)$/);
94138

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

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

156+
// Allow people to see where this installation is.
157+
app.get('/ag-solo-basedir', (req, res) => {
158+
res.contentType('text/plain');
159+
res.write(basedir);
160+
res.end();
161+
});
162+
112163
// accept POST messages to arbitrary endpoints
113164
app.post('*', (req, res) => {
114-
if (!validateOrigin(req)) {
115-
res.json({ ok: false, rej: 'Unauthorized Origin' });
165+
if (!validateOriginAndWebkey(req)) {
166+
res.json({ ok: false, rej: 'Unauthorized' });
116167
return;
117168
}
118169

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

0 commit comments

Comments
 (0)