diff --git a/README.md b/README.md index 50e5764..0d9ad34 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Telebit™ Remote -Because friends don't let friends localhost™ +The T-Rex Long-Arm of the Internet + +because friends don't let friends localhost | Sponsored by [ppl](https://ppl.family) | **Telebit Remote** @@ -524,7 +526,7 @@ rm -rf ~/.config/telebit ~/.local/share/telebit Browser Library ======= -This is implemented with websockets, so you should be able to +This is implemented with websockets, so browser compatibility is a hopeful future outcome. Would love help. LICENSE ======= diff --git a/bin/telebit-remote.js b/bin/telebit-remote.js index c1c77ac..4bae802 100755 --- a/bin/telebit-remote.js +++ b/bin/telebit-remote.js @@ -1,18 +1,22 @@ #!/usr/bin/env node (function () { 'use strict'; +/*global Promise*/ var pkg = require('../package.json'); var os = require('os'); //var url = require('url'); var fs = require('fs'); +var util = require('util'); var path = require('path'); -var http = require('http'); //var https = require('https'); var YAML = require('js-yaml'); var TOML = require('toml'); var TPLS = TOML.parse(fs.readFileSync(path.join(__dirname, "../lib/en-us.toml"), 'utf8')); +var JWT = require('../lib/jwt.js'); +var keypairs = require('keypairs'); + /* if ('function' !== typeof TOML.stringify) { TOML.stringify = require('json2toml'); @@ -22,9 +26,13 @@ var recase = require('recase').create({}); var camelCopy = recase.camelCopy.bind(recase); //var snakeCopy = recase.snakeCopy.bind(recase); -var urequest = require('@coolaj86/urequest'); +var urequest = require('@root/request'); +var urequestAsync = require('util').promisify(urequest); var common = require('../lib/cli-common.js'); +var defaultConfPath = path.join(os.homedir(), '.config/telebit'); +var defaultConfFile = path.join(defaultConfPath, 'telebit.yml'); + var argv = process.argv.slice(2); var argIndex = argv.indexOf('--config'); @@ -39,6 +47,30 @@ if (-1 === argIndex) { } if (-1 !== argIndex) { confpath = argv.splice(argIndex, 2)[1]; + state.configArg = confpath; + // shortname + if (state.configArg) { + if (/^[\w:\.\-]+$/.test(state.configArg)) { + state.configDir = defaultConfPath; + state.configFile = path.join(defaultConfPath, confpath + '.yml'); + } else if (/[\/\\]$/.test(state.configArg)) { + state.configDir = state.configArg; + state.configFile = path.join(state.configDir, 'telebit.yml'); + } else if (/[\/\\][^\.\/\\]\.[^\.\/\\]$/.test(state.configArg)) { + state.configDir = path.pathname(state.configArg); + state.configFile = state.configArg; + } else { + console.error(); + console.error("Not a valid config path, file, or shortname: '%s'", state.configArg); + console.error(); + console.error("Valid config options look like this:"); + console.error(" Full path: ~/.config/telebit/telebit.yml (full path)"); + console.error(" Directory: ~/.config/telebit/ (directory)"); + console.error(" Shortname: lucky-duck (shortname)"); + process.exit(37); + } + confpath = state.configFile; + } } argIndex = argv.indexOf('--tty'); if (-1 !== argIndex) { @@ -57,7 +89,9 @@ function help() { var verstr = [ pkg.name + ' remote v' + pkg.version ]; if (!confpath) { - confpath = path.join(os.homedir(), '.config/telebit/telebit.yml'); + state.configDir = defaultConfPath; + state.configFile = defaultConfFile; + confpath = state.configFile; verstr.push('(--config \'' + confpath.replace(new RegExp('^' + os.homedir()), '~') + '\')'); } @@ -72,7 +106,9 @@ if (!confpath || /^--/.test(confpath)) { process.exit(1); } -function askForConfig(state, mainCb) { +var Console = {}; +Console.setup = function (state) { + if (Console.rl) { return; } var fs = require('fs'); var ttyname = '/dev/tty'; var stdin = useTty ? fs.createReadStream(ttyname, { @@ -87,6 +123,35 @@ function askForConfig(state, mainCb) { , terminal: !/^win/i.test(os.platform()) && !useTty }); state._useTty = useTty; + Console.rl = rl; +}; +Console.teardown = function () { + if (!Console.rl) { return; } + // https://github.com/nodejs/node/issues/21319 + if (useTty) { try { Console.stdin.push(null); } catch(e) { /*ignore*/ } } + Console.rl.close(); + if (useTty) { try { Console.stdin.close(); } catch(e) { /*ignore*/ } } + Console.rl = null; +}; + +function askEmail(cb) { + Console.setup(state); + if (state.config.email) { cb(); return; } + console.info(TPLS.remote.setup.email); + // TODO attempt to read email from npmrc or the like? + Console.rl.question('email: ', function (email) { + // TODO validate email domain + email = /@/.test(email) && email.trim(); + if (!email) { askEmail(cb); return; } + state.config.email = email.trim(); + state.config.agreeTos = true; + console.info(""); + setTimeout(cb, 250); + }); +} + +function askForConfig(state, mainCb) { + Console.setup(state); // NOTE: Use of setTimeout // We're using setTimeout just to make the user experience a little @@ -95,20 +160,32 @@ function askForConfig(state, mainCb) { // >= 300ms is long enough to become distracted and change focus (a full blink, time for an idea to form as a thought) // <= 100ms is shorter than normal human reaction time (ability to place events chronologically, which happened first) // ~ 150-250ms is the sweet spot for most humans (long enough to notice change and not be jarred, but stay on task) + + function askAgree(cb) { + if (state.config.agreeTos) { cb(); return; } + console.info(""); + console.info(""); + console.info("Do you accept the terms of service for each and all of the following?"); + console.info(""); + console.info("\tTelebit - End-to-End Encrypted Relay"); + console.info("\tGreenlock - Automated HTTPS"); + console.info("\tLet's Encrypt - TLS Certificates"); + console.info(""); + console.info("Type 'y' or 'yes' to accept these Terms of Service."); + console.info(""); + Console.rl.question('agree to all? [y/N]: ', function (resp) { + resp = resp.trim(); + if (!/^y(es)?$/i.test(resp) && 'true' !== resp) { + throw new Error("You didn't accept the Terms of Service... not sure what to do..."); + } + state.config.agreeTos = true; + console.info(""); + setTimeout(cb, 250); + }); + } + var firstSet = [ - function askEmail(cb) { - if (state.config.email) { cb(); return; } - console.info(TPLS.remote.setup.email); - // TODO attempt to read email from npmrc or the like? - rl.question('email: ', function (email) { - email = /@/.test(email) && email.trim(); - if (!email) { askEmail(cb); return; } - state.config.email = email.trim(); - state.config.agreeTos = true; - console.info(""); - setTimeout(cb, 250); - }); - } + askEmail , function askRelay(cb) { function checkRelay(relay) { // TODO parse and check https://{{relay}}/.well-known/telebit.cloud/directives.json @@ -123,13 +200,10 @@ function askForConfig(state, mainCb) { return; } if (200 !== resp.statusCode || (Buffer.isBuffer(body) || 'object' !== typeof body) || !body.api_host) { - console.warn("==================="); - console.warn(" WARNING "); - console.warn("==================="); - console.warn(""); - console.warn("[" + resp.statusCode + "] '" + urlstr + "'"); - console.warn("This server does not describe a current telebit version (but it may still work)."); - console.warn(""); + console.warn(TPLS.remote.setup.fail_relay_check + .replace(/{{\s*status_code\s*}}/, resp.statusCode) + .replace(/{{\s*url\s*}}/, urlstr) + ); console.warn(body); } else if (body && body.pair_request) { state._can_pair = true; @@ -144,7 +218,7 @@ function askForConfig(state, mainCb) { console.info(""); console.info("What relay will you be using? (press enter for default)"); console.info(""); - rl.question('relay [default: telebit.cloud]: ', checkRelay); + Console.rl.question('relay [default: telebit.cloud]: ', checkRelay); } , function checkRelay(cb) { nextSet = []; @@ -158,30 +232,9 @@ function askForConfig(state, mainCb) { } ]; var standardSet = [ - // There are questions that we need to aks in the CLI + // There are questions that we need to ask in the CLI // if we can't guarantee that they are being asked in the web interface - function askAgree(cb) { - if (state.config.agreeTos) { cb(); return; } - console.info(""); - console.info(""); - console.info("Do you accept the terms of service for each and all of the following?"); - console.info(""); - console.info("\tTelebit - End-to-End Encrypted Relay"); - console.info("\tGreenlock - Automated HTTPS"); - console.info("\tLet's Encrypt - TLS Certificates"); - console.info(""); - console.info("Type 'y' or 'yes' to accept these Terms of Service."); - console.info(""); - rl.question('agree to all? [y/N]: ', function (resp) { - resp = resp.trim(); - if (!/^y(es)?$/i.test(resp) && 'true' !== resp) { - throw new Error("You didn't accept the Terms of Service... not sure what to do..."); - } - state.config.agreeTos = true; - console.info(""); - setTimeout(cb, 250); - }); - } + askAgree , function askUpdates(cb) { // required means transactional, security alerts, mandatory updates var options = [ 'newsletter', 'important', 'required' ]; @@ -190,7 +243,7 @@ function askForConfig(state, mainCb) { console.info(""); console.info("What updates would you like to receive? (" + options.join(',') + ")"); console.info(""); - rl.question('messages (default: important): ', function (updates) { + Console.rl.question('messages (default: important): ', function (updates) { state._updates = (updates || '').trim().toLowerCase(); if (!state._updates) { state._updates = 'important'; } if (-1 === options.indexOf(state._updates)) { askUpdates(cb); return; } @@ -211,7 +264,7 @@ function askForConfig(state, mainCb) { console.info(""); console.info("Contribute project telemetry data? (press enter for default [yes])"); console.info(""); - rl.question('telemetry [Y/n]: ', function (telemetry) { + Console.rl.question('telemetry [Y/n]: ', function (telemetry) { if (!telemetry || /^y(es)?$/i.test(telemetry)) { state.config.telemetry = true; } @@ -220,7 +273,8 @@ function askForConfig(state, mainCb) { } ]; var fossSet = [ - function askTokenOrSecret(cb) { + askAgree + , function askTokenOrSecret(cb) { if (state._can_pair || state.token || state.config.token || state.secret || state.config.secret) { cb(); return; } console.info(""); @@ -234,11 +288,10 @@ function askForConfig(state, mainCb) { console.info("\tShared Secret (HMAC hex)"); //console.info("\tPrivate key (hex)"); console.info(""); - rl.question('auth: ', function (resp) { - var jwt = require('jsonwebtoken'); + Console.rl.question('auth: ', function (resp) { resp = (resp || '').trim(); try { - jwt.decode(resp); + JWT.decode(resp); state.config.token = resp; } catch(e) { // is not jwt @@ -263,7 +316,7 @@ function askForConfig(state, mainCb) { console.info("What servername(s) will you be relaying here?"); console.info("(use a comma-separated list such as example.com,example.net)"); console.info(""); - rl.question('domain(s): ', function (resp) { + Console.rl.question('domain(s): ', function (resp) { resp = (resp || '').trim().split(/,/g); if (!resp.length) { askServernames(); return; } // TODO validate the domains @@ -278,7 +331,7 @@ function askForConfig(state, mainCb) { console.info("What tcp port(s) will you be relaying here?"); console.info("(use a comma-separated list such as 2222,5050)"); console.info(""); - rl.question('port(s) [default:none]: ', function (resp) { + Console.rl.question('port(s) [default:none]: ', function (resp) { resp = (resp || '').trim().split(/,/g); if (!resp.length) { askPorts(); return; } // TODO validate the domains @@ -292,10 +345,7 @@ function askForConfig(state, mainCb) { function next() { var q = nextSet.shift(); if (!q) { - // https://github.com/nodejs/node/issues/21319 - if (useTty) { try { stdin.push(null); } catch(e) { /*ignore*/ } } - rl.close(); - if (useTty) { try { stdin.close(); } catch(e) { /*ignore*/ } } + Console.teardown(); mainCb(null, state); return; } @@ -305,187 +355,295 @@ function askForConfig(state, mainCb) { next(); } -var utils = { - request: function request(opts, fn) { - if (!opts) { opts = {}; } - var service = opts.service || 'config'; - var req = http.request({ - socketPath: state._ipc.path - , method: opts.method || 'GET' - , path: '/rpc/' + service - }, function (resp) { - var body = ''; +var RC; - function finish() { - if (200 !== resp.statusCode) { - console.warn(resp.statusCode); - console.warn(body || ('get' + service + ' failed')); - //cb(new Error("not okay"), body); - return; - } - - if (!body) { fn(null, null); return; } - - try { - body = JSON.parse(body); - } catch(e) { - // ignore - } - - fn(null, body); - } - - if (resp.headers['content-length']) { - resp.on('data', function (chunk) { - body += chunk.toString(); - }); - resp.on('end', function () { - finish(); - }); - } else { - finish(); - } - }); - req.on('error', function (err) { - // ENOENT - never started, cleanly exited last start, or creating socket at a different path - // ECONNREFUSED - leftover socket just needs to be restarted - if ('ENOENT' === err.code || 'ECONNREFUSED' === err.code) { - if (opts._taketwo) { - console.error("Either the telebit service was not already (and could not be started) or its socket could not be written to."); - console.error(err); - return; - } - require('../usr/share/install-launcher.js').install({ env: process.env }, function (err) { - if (err) { fn(err); return; } - opts._taketwo = true; - setTimeout(function () { - utils.request(opts, fn); - }, 2500); - }); - return; - } - if ('ENOTSOCK' === err.code) { - console.error(err); - return; - } +function bootstrap(opts) { + state.key = opts.key; + // Create / retrieve account (sign-in, more or less) + // TODO hit directory resource /.well-known/openid-configuration -> acme_uri (?) + // Occassionally rotate the key just for the sake of testing the key rotation + return urequestAsync({ + method: 'HEAD' + , url: RC.resolve('/acme/new-nonce') + , headers: { "User-Agent": 'Telebit/' + pkg.version } + }).catch(RC.createRelauncher(bootstrap._replay(opts), bootstrap._state)).catch(function (err) { + if ('ENOENT' === err.code || 'ECONNREFUSED' === err.code) { + console.error("Either the telebit service was not already (and could not be started) or its socket could not be written to."); console.error(err); - return; - }); - req.end(); - } -, putConfig: function putConfig(service, args, fn) { - var req = http.request({ - socketPath: state._ipc.path - , method: 'POST' - , path: '/rpc/' + service + '?_body=' + encodeURIComponent(JSON.stringify(args)) - }, function (resp) { - - function finish() { - if ('function' === typeof fn) { - fn(null, resp); - return; - } - - console.info(""); - if (200 !== resp.statusCode) { - console.warn("'" + service + "' may have failed." - + " Consider peaking at the logs either with 'journalctl -xeu telebit' or /opt/telebit/var/log/error.log"); - console.warn(resp.statusCode, body); - //cb(new Error("not okay"), body); - return; - } - - if (!body) { - console.info("👌"); - return; - } - - try { - body = JSON.parse(body); - } catch(e) { - // ignore - } - - if ("AWAIT_AUTH" === body.code) { - console.info(body.message); - } else if ("CONFIG" === body.code) { - delete body.code; - //console.info(TOML.stringify(body)); - console.info(YAML.safeDump(body)); - } else { - if ('http' === body.module) { - // TODO we'll support slingshot-ing in the future - if (String(body.local) === String(parseInt(body.local, 10))) { - console.info('> Forwarding https://' + body.remote + ' => localhost:' + body.local); - } else { - console.info('> Serving ' + body.local + ' as https://' + body.remote); - } - } else if ('tcp' === body.module) { - console.info('> Forwarding ' + state.config.relay + ':' + body.remote + ' => localhost:' + body.local); - } else if ('ssh' === body.module) { - //console.info('> Forwarding ' + state.config.relay + ' -p ' + JSON.stringify(body) + ' => localhost:' + body.local); - console.info('> Forwarding ssh+https (openssl proxy) => localhost:' + body.local); - } else { - console.info(JSON.stringify(body, null, 2)); - } - console.info(); - } - } - - var body = ''; - if (resp.headers['content-length']) { - resp.on('data', function (chunk) { - body += chunk.toString(); - }); - resp.on('end', function () { - finish(); - }); - } else { - finish(); - } - }); - req.on('error', function (err) { - console.error('Put Config Error:'); + } else if ('ENOTSOCK' === err.code) { + console.error("Strange socket error:"); console.error(err); - return; + // Is this ignorable? + //return; + } else { + console.error("Unknown error:"); + console.error(err); + } + console.error(err); + process.exit(17); + }).then(function (resp) { + var nonce = resp.headers['replay-nonce']; + var newAccountUrl = RC.resolve('/acme/new-acct'); + var contact = []; + if (opts.email) { + contact.push("mailto:" + opts.email); + } + return keypairs.signJws({ + jwk: state.key + , protected: { + // alg will be filled out automatically + jwk: state.pub + , kid: false + , nonce: nonce + , url: newAccountUrl + } + , payload: JSON.stringify({ + // We can auto-agree here because the client is the user agent of the primary user + termsOfServiceAgreed: true + , contact: contact // I don't think we have email yet... + , onlyReturnExisting: opts.onlyReturnExisting || !opts.email + //, externalAccountBinding: null + }) + }).then(function (jws) { + return urequestAsync({ + url: newAccountUrl + , method: 'POST' + , json: jws // TODO default to post when body is present + , headers: { + "Content-Type": 'application/jose+json' + , "User-Agent": 'Telebit/' + pkg.version + } + }).then(function (resp) { + //nonce = resp.headers['replay-nonce']; + if (!resp.body || 'valid' !== resp.body.status) { + throw new Error("Did not successfully create or restore account:\n" + + "Email: " + opts.email + "\n" + + "Request JWS:\n" + JSON.stringify(jws, null, 2) + "\n" + + "Response:\n" + + "Headers:\n" + JSON.stringify(resp.headers, null, 2) + "\n" + + "Body:\n" + resp.body + "\n" + ); + } + return resp.body; + }); }); - req.end(); - } -}; - -// Two styles: -// http 3000 -// http modulename -function makeRpc(key) { - if (key !== argv[0]) { - return false; - } - utils.putConfig(argv[0], argv.slice(1)); - return true; -} - -function packConfig(config) { - return Object.keys(config).map(function (key) { - var val = config[key]; - if ('undefined' === val) { - throw new Error("'undefined' used as a string value"); - } - if ('undefined' === typeof val) { - //console.warn('[DEBUG]', key, 'is present but undefined'); - return; - } - if (val && 'object' === typeof val && !Array.isArray(val)) { - val = JSON.stringify(val); - } - return key + ':' + val; // converts arrays to strings with , }); } +bootstrap._state = {}; +bootstrap._replay = function (_opts) { + return function (opts) { + // supply opts to match reverse signature (.length checking) + opts = _opts; + return bootstrap(opts); + }; +}; -function getToken(err, state) { - if (err) { - console.error("Error while initializing config [init]:"); - throw err; +function handleConfig(config) { + var _config = state.config || {}; + + state.config = config; + var verstrd = [ pkg.name + ' daemon v' + state.config.version ]; + if (state.config.version && state.config.version !== pkg.version) { + console.info(verstr.join(' '), verstrd.join(' ')); + } else { + console.info(verstr.join(' ')); } + + // + // check for init first, before anything else + // because it has arguments that may help in + // the next steps + // + if (-1 !== argv.indexOf('init')) { + parsers.init(argv, function (err) { + if (err) { + console.error("Error while initializing config [init]:"); + throw err; + } + getToken(function (err) { + if (err) { + console.error("Error while getting token [init]:"); + throw err; + } + parseCli(state); + }); + }); + // TODO XXXXXXX + return; + } + + if (!state.config.relay || !state.config.token) { + if (!state.config.relay) { + try { + state.config.relay = 'telebit.cloud'; + } catch(e) { + console.error(state.config); + throw e; + } + } + + //console.log("question the user?", Date.now()); + askForConfig(state, function (err, state) { + // no errors actually get passed, so this is just future-proofing + if (err) { throw err; } + + if (!state.config.token && state._can_pair) { + state.config._otp = common.otp(); + } + + //console.log("done questioning:", Date.now()); + if (!state.token && !state.config.token) { + if (err) { + console.error("Error while initializing config [init]:"); + throw err; + } + getToken(function (err) { + if (err) { + console.error("Error while getting token [init]:"); + throw err; + } + parseCli(state); + }); + } else { + parseCli(state); + } + }); + return; + } else { + Console.teardown(); + } + + //console.log("no questioning:"); + parseCli(state); +} + +function parseCli(/*state*/) { + var special = [ + 'false', 'none', 'off', 'disable' + , 'true', 'auto', 'on', 'enable' + ]; + if (-1 !== argv.indexOf('init')) { + RC.request({ service: 'list', method: 'POST', data: [] }, handleRemoteRequest('list')); + return; + } + + if ([ 'ssh', 'http', 'tcp' ].some(function (key) { + if (key !== argv[0]) { + return false; + } + if (argv[1]) { + if (String(argv[1]) === String(parseInt(argv[1], 10))) { + // looks like a port + argv[1] = parseInt(argv[1], 10); + } else if (/\/|\\/.test(argv[1])) { + // looks like a path + argv[1] = path.resolve(argv[1]); + // TODO make a default assignment here + } else if (-1 === special.indexOf(argv[1])) { + console.error("Not sure what you meant by '" + argv[1] + "'."); + console.error("Remember: paths should begin with ." + path.sep + ", like '." + path.sep + argv[1] + "'"); + return true; + } + RC.request({ service: argv[0], method: 'POST', data: argv.slice(1) }, handleRemoteRequest(argv[0])); + return true; + } + help(); + return true; + })) { + return; + } + + // Two styles: + // http 3000 + // http modulename + function makeRpc(key) { + if (key !== argv[0]) { + return false; + } + RC.request({ service: argv[0], method: 'POST', data: argv.slice(1) }, handleRemoteRequest(argv[0])); + return true; + } + if ([ 'status', 'enable', 'disable', 'restart', 'list', 'save' ].some(makeRpc)) { + return; + } + + help(); + process.exit(11); +} + +function handleRemoteRequest(service, fn) { + return function (err, body) { + if ('function' === typeof fn) { + fn(err, body); // XXX was resp + return; + } + console.info(""); + if (err) { + console.warn("'" + service + "' may have failed." + + " Consider peaking at the logs either with 'journalctl -xeu telebit' or /opt/telebit/var/log/error.log"); + console.warn(err.statusCode, err.message); + //cb(new Error("not okay"), body); + return; + } + + if (!body) { + console.info("👌"); + return; + } + + try { + body = JSON.parse(body); + } catch(e) { + // ignore + + } + + if ("AWAIT_AUTH" === body.code) { + console.info(body.message); + } else if ("CONFIG" === body.code) { + delete body.code; + //console.info(TOML.stringify(body)); + console.info(YAML.safeDump(body)); + } else { + if ('http' === body.module) { + // TODO we'll support slingshot-ing in the future + if (body.local) { + if (String(body.local) === String(parseInt(body.local, 10))) { + console.info('> Forwarding https://' + body.remote + ' => localhost:' + body.local); + } else { + console.info('> Serving ' + body.local + ' as https://' + body.remote); + } + } else { + console.info('> Rejecting End-to-End Encrypted HTTPS for now'); + } + } else if ('tcp' === body.module) { + if (body.local) { + console.info('> Forwarding ' + state.config.relay + ':' + body.remote + ' => localhost:' + body.local); + } else { + console.info('> Rejecting Legacy TCP'); + } + } else if ('ssh' === body.module) { + //console.info('> Forwarding ' + state.config.relay + ' -p ' + JSON.stringify(body) + ' => localhost:' + body.local); + if (body.local) { + console.info('> Forwarding ssh+https (openssl proxy) => localhost:' + body.local); + } else { + console.info('> Rejecting SSH-over-HTTPS for now'); + } + } else if ('status' === body.module) { + // TODO funny one this one + if (body.port) { + console.info('http://localhost:' + (body.port)); + } + console.info(JSON.stringify(body, null, 2)); + } else { + console.info(JSON.stringify(body, null, 2)); + } + console.info(); + } + }; +} + +function getToken(fn) { state.relay = state.config.relay; // { _otp, config: {} } @@ -511,17 +669,8 @@ function getToken(err, state) { state.config._otp = state.config._otp = authReq.otp; if (!state.config.token && state._can_pair) { - console.info(""); - console.info("=============================================="); - console.info(" Hey, Listen! "); - console.info("=============================================="); - console.info(" "); - console.info(" GO CHECK YOUR EMAIL! "); - console.info(" "); - console.info(" DEVICE PAIR CODE: 0000 ".replace(/0000/g, state.config._otp)); - console.info(" "); - console.info("=============================================="); - console.info(""); + // Hey, Listen + console.info(TPLS.remote.code.replace(/0000/g, state.config._otp)); } next(); @@ -532,7 +681,7 @@ function getToken(err, state) { state._connecting = true; // TODO use php-style object querification - utils.putConfig('config', packConfig(state.config), function (err/*, body*/) { + RC.request({ service: 'config', method: 'POST', data: state.config }, handleRemoteRequest('config', function (err/*, body*/) { if (err) { state._error = err; console.error("Error while initializing config [connect]:"); @@ -541,7 +690,7 @@ function getToken(err, state) { } console.info("waiting..."); next(); - }); + })); } , offer: function (token, next) { //console.log("[offer] Pairing Enabled by Relay"); @@ -551,12 +700,12 @@ function getToken(err, state) { } state._connecting = true; try { - require('jsonwebtoken').decode(token); - //console.log(require('jsonwebtoken').decode(token)); + JWT.decode(token); + //console.log(JWT.decode(token)); } catch(e) { console.warn("[warning] could not decode token"); } - utils.putConfig('config', packConfig(state.config), function (err/*, body*/) { + RC.request({ service: 'config', method: 'POST', data: state.config }, handleRemoteRequest('config', function (err/*, body*/) { if (err) { state._error = err; console.error("Error while initializing config [offer]:"); @@ -565,14 +714,14 @@ function getToken(err, state) { } //console.log("Pairing Enabled Locally"); next(); - }); + })); } , granted: function (_, next) { //console.log("[grant] Pairing complete!"); next(); } , end: function () { - utils.putConfig('enable', [], function (err) { + RC.request({ service: 'enable', method: 'POST', data: [] }, handleRemoteRequest('enable', function (err) { if (err) { console.error(err); return; } console.info("Success"); @@ -592,116 +741,23 @@ function getToken(err, state) { } // end workaround - parseCli(state); - }); + //parseCli(state); + fn(); + })); } }); } -function parseCli(/*state*/) { - var special = [ - 'false', 'none', 'off', 'disable' - , 'true', 'auto', 'on', 'enable' - ]; - if (-1 !== argv.indexOf('init')) { - utils.putConfig('list', []/*, function (err) { - }*/); - return; - } - - if ([ 'ssh', 'http', 'tcp' ].some(function (key) { - if (key !== argv[0]) { - return false; - } - if (argv[1]) { - if (String(argv[1]) === String(parseInt(argv[1], 10))) { - // looks like a port - argv[1] = parseInt(argv[1], 10); - } else if (/\/|\\/.test(argv[1])) { - // looks like a path - argv[1] = path.resolve(argv[1]); - // TODO make a default assignment here - } else if (-1 === special.indexOf(argv[1])) { - console.error("Not sure what you meant by '" + argv[1] + "'."); - console.error("Remember: paths should begin with ." + path.sep + ", like '." + path.sep + argv[1] + "'"); - return true; - } - utils.putConfig(argv[0], argv.slice(1)); - return true; - } - return true; - })) { - return; - } - - if ([ 'status', 'enable', 'disable', 'restart', 'list', 'save' ].some(makeRpc)) { - return; - } - - help(); - process.exit(11); -} - -function handleConfig(err, config) { - //console.log('CONFIG'); - //console.log(config); - state.config = config; - var verstrd = [ pkg.name + ' daemon v' + state.config.version ]; - if (state.config.version && state.config.version !== pkg.version) { - console.info(verstr.join(' '), verstrd.join(' ')); - } else { - console.info(verstr.join(' ')); - } - - if (err) { console.error(err); process.exit(101); return; } - - // - // check for init first, before anything else - // because it has arguments that may help in - // the next steps - // - if (-1 !== argv.indexOf('init')) { - parsers.init(argv, getToken); - return; - } - - if (!state.config.relay || !state.config.token) { - if (!state.config.relay) { - state.config.relay = 'telebit.cloud'; - } - - //console.log("question the user?", Date.now()); - askForConfig(state, function (err, state) { - // no errors actually get passed, so this is just future-proofing - if (err) { throw err; } - - if (!state.config.token && state._can_pair) { - state.config._otp = common.otp(); - } - - //console.log("done questioning:", Date.now()); - if (!state.token && !state.config.token) { - getToken(err, state); - } else { - parseCli(state); - } - }); - return; - } - - //console.log("no questioning:"); - parseCli(state); -} - -function parseConfig(err, text) { +function parseConfig(text) { + var _clientConfig; try { - state._clientConfig = JSON.parse(text || '{}'); + _clientConfig = JSON.parse(text || '{}'); } catch(e1) { try { - state._clientConfig = YAML.safeLoad(text || '{}'); + _clientConfig = YAML.safeLoad(text || '{}'); } catch(e2) { try { - state._clientConfig = TOML.parse(text || ''); + _clientConfig = TOML.parse(text || ''); } catch(e3) { console.error(e1.message); console.error(e2.message); @@ -711,29 +767,7 @@ function parseConfig(err, text) { } } - state._clientConfig = camelCopy(state._clientConfig || {}) || {}; - common._init( - // make a default working dir and log dir - state._clientConfig.root || path.join(os.homedir(), '.local/share/telebit') - , (state._clientConfig.root && path.join(state._clientConfig.root, 'etc')) - || path.resolve(common.DEFAULT_CONFIG_PATH, '..') - ); - state._ipc = common.pipename(state._clientConfig, true); - - if (!Object.keys(state._clientConfig).length) { - console.info('(' + state._ipc.comment + ": " + state._ipc.path + ')'); - console.info(""); - } - - if ((err && 'ENOENT' === err.code) || !Object.keys(state._clientConfig).length) { - if (!err || 'ENOENT' === err.code) { - //console.warn("Empty config file. Run 'telebit init' to configure.\n"); - } else { - console.warn("Couldn't load config:\n\n\t" + err.message + "\n"); - } - } - - utils.request({ service: 'config' }, handleConfig); + return camelCopy(_clientConfig || {}) || {}; } var parsers = { @@ -813,6 +847,102 @@ var parsers = { } }; -fs.readFile(confpath, 'utf8', parseConfig); +// +// Start by reading the config file, before all else +// +util.promisify(fs.readFile)(confpath, 'utf8').catch(function (err) { + if (err && 'ENOENT' !== err.code) { + console.warn("Couldn't load config:\n\n\t" + err.message + "\n"); + } +}).then(function (text) { + state._clientConfig = parseConfig(text); + RC = require('../lib/rc/index.js').create(state); // adds state._ipc + if (!Object.keys(state._clientConfig).length) { + console.info('(' + state._ipc.comment + ": " + state._ipc.path + ')'); + console.info(""); + } + RC.requestAsync = require('util').promisify(RC.request); +}).then(function () { + var keystore = require('../lib/keystore.js').create(state); + state.keystore = keystore; + state.keystoreSecure = !keystore.insecure; + keystore.all().then(function (list) { + var keyext = '.key.jwk.json'; + var key; + var p; + + // TODO create map by account and index into that map to get the master key + // and sort keys in the process + list.some(function (el) { + if (keyext === el.account.slice(-keyext.length) + && el.password.kty && el.password.kid) { + key = el.password; + return true; + } + }); + + if (key) { + p = Promise.resolve(key); + } else { + p = keypairs.generate().then(function (pair) { + var jwk = pair.private; + return keypairs.thumbprint({ jwk: jwk }).then(function (kid) { + var size = (jwk.crv || Buffer.from(jwk.n, 'base64').byteLength * 8); + jwk.kid = kid; + console.info("Generated new %s %s private key with thumbprint %s", jwk.kty, size, kid); + return keystore.set(kid + keyext, jwk).then(function () { + return jwk; + }); + }); + }); + } + + return p.then(function (key) { + state.key = key; + state.pub = keypairs.neuter({ jwk: key }); + // we don't have config yet + state.config = {}; + return bootstrap({ key: state.key, onlyReturnExisting: true }).catch(function (/*#err*/) { + //#console.warn("[DEBUG] local account not created?"); + //#console.warn(err); + // Ask for email address. The prior email may have been bad + return require('util').promisify(askEmail)().then(function () { + return bootstrap({ key: state.key, email: state.config.email }); + }); + }).catch(function (err) { + console.error(err); + console.error("You may need to go into the web interface and allow Telebit Client by ID '" + key.kid + "'"); + process.exit(10); + }).then(function (result) { + //#console.log("Telebit Account Bootstrap result:"); + //#console.log(result); + state.config.email = (result.contact[0]||'').replace(/mailto:/, ''); + state.config.agreeTos = true; + var p2; + if (state.key.sub === state.config.email) { + p2 = Promise.resolve(state.key); + } else { + state.key.sub = state.config.email; + p2 = keystore.set(state.key.kid + keyext, state.key); + } + return p2.then(function () { + return RC.requestAsync({ service: 'config', method: 'GET' }).then(function (config) { + if (!config.email) { + config.email = state.config.email; + } + if (!config.agreeTos) { + config.agreeTos = state.config.agreeTos; + } + handleConfig(config); + }); + }); + }); + }); + }); +}).catch(function (err) { + console.error("Telebit failed to stay running:"); + console.error(err); + process.exit(101); +}); }()); diff --git a/bin/telebit.js b/bin/telebit.js index 21dbc68..c880681 100755 --- a/bin/telebit.js +++ b/bin/telebit.js @@ -21,6 +21,7 @@ if ('rsync' === process.argv[2]) { require('sclient/bin/sclient.js'); return; } +// handle ssh client rather than ssh https tunnel if ('ssh' === process.argv[2] && /[\w-]+\.[a-z]{2,}/i.test(process.argv[3])) { process.argv.splice(1,1,'sclient'); process.argv.splice(2,1,'ssh'); diff --git a/bin/telebitd.js b/bin/telebitd.js index d70f4df..4db9fcd 100755 --- a/bin/telebitd.js +++ b/bin/telebitd.js @@ -10,11 +10,16 @@ try { } var pkg = require('../package.json'); +var Keypairs = require('keypairs'); -var url = require('url'); +var crypto = require('crypto'); +//var url = require('url'); var path = require('path'); var os = require('os'); var fs = require('fs'); +var fsp = fs.promises; +var urequest = require('@root/request'); +var urequestAsync = require('util').promisify(urequest); var common = require('../lib/cli-common.js'); var http = require('http'); var TOML = require('toml'); @@ -23,8 +28,15 @@ var recase = require('recase').create({}); var camelCopy = recase.camelCopy.bind(recase); var snakeCopy = recase.snakeCopy.bind(recase); var TPLS = TOML.parse(fs.readFileSync(path.join(__dirname, "../lib/en-us.toml"), 'utf8')); +var startTime = Date.now(); +var connectTimes = []; +var isConnected = false; +var eggspress = require('../lib/eggspress.js'); +var keypairs = require('keypairs'); +var KEYEXT = '.key.jwk.json'; +var PUBEXT = '.pub.jwk.json'; -var TelebitRemote = require('../').TelebitRemote; +var TelebitRemote = require('../lib/daemon/index.js').TelebitRemote; var state = { homedir: os.homedir(), servernames: {}, ports: {}, keepAlive: { state: false } }; @@ -67,14 +79,11 @@ if (!confpath || /^--/.test(confpath)) { } state._confpath = confpath; -var tokenpath = path.join(path.dirname(state._confpath), 'access_token.txt'); -var token; -try { - token = fs.readFileSync(tokenpath, 'ascii').trim(); - //console.log('[DEBUG] access_token', typeof token, token); -} catch(e) { - // ignore -} +var keystore = require('../lib/keystore.js').create({ + name: "Telebit Daemon" +, configDir: path.basename(confpath) +}); + var controlServer; var myRemote; @@ -103,11 +112,23 @@ function getServername(servernames, sub) { })[0]; } +/*global Promise*/ +var _savingConfig = Promise.resolve(); function saveConfig(cb) { - fs.writeFile(confpath, YAML.safeDump(snakeCopy(state.config)), cb); + // simple sequencing chain so that write corruption is not possible + _savingConfig = _savingConfig.then(function () { + return fsp.writeFile(confpath, YAML.safeDump(snakeCopy(state.config))).then(function () { + try { + cb(); + } catch(e) { + console.error(e.stack); + process.exit(47); + } + }).catch(cb); + }); } var controllers = {}; -controllers.http = function (req, res, opts) { +controllers.http = function (req, res) { function getAppname(pathname) { // port number if (String(pathname) === String(parseInt(pathname, 10))) { @@ -123,18 +144,36 @@ controllers.http = function (req, res, opts) { name = name.replace(/\./, '-').replace(/-+/, '-'); return name; } - if (!opts.body) { + + function assign(target, handler, indexes) { + target.handler = handler; + if (indexes) { + target.indexes = true; + } else { + delete target.indexes; + } + } + + if (!req.body) { res.statusCode = 422; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({"error":{"message":"module \'http\' needs more arguments"}})); + res.send({"error":{"message":"module \'http\' needs some arguments"}}); return; } + var active = true; - var portOrPath = opts.body[0]; - var appname = getAppname(portOrPath); - var subdomain = opts.body[1]; + var portOrPath = req.body.handler || req.body[0]; + var subdomain = req.body.name || req.body[1]; + var indexes = req.body.indexes; var remoteHost; + if (!portOrPath) { + res.statusCode = 422; + res.send({ error: { message: "module 'http' needs port or path" } }); + return; + } + + var appname = getAppname(portOrPath); + // Assign an FQDN to brief subdomains // ex: foo => foo.rando.telebit.cloud if (subdomain && !/\./.test(subdomain)) { @@ -169,7 +208,13 @@ controllers.http = function (req, res, opts) { return; } }); - delete state.servernames[subdomain]; + if (state.servernames[subdomain]) { + // TODO remove all non-essential keys + delete state.servernames[subdomain].handler; + if (state.servernames[subdomain].sub) { + delete state.servernames[subdomain]; + } + } remoteHost = 'none'; } else if (subdomain && 'none' !== subdomain) { // use a subdomain with this handler @@ -183,7 +228,7 @@ controllers.http = function (req, res, opts) { if ('none' === portOrPath) { delete state.servernames[subdomain].handler; } else { - state.servernames[subdomain].handler = portOrPath; + assign(state.servernames[subdomain], portOrPath, indexes); } remoteHost = subdomain; } else { @@ -202,7 +247,7 @@ controllers.http = function (req, res, opts) { if (!state.servernames[prefix]) { state.servernames[prefix] = { sub: undefined }; } - state.servernames[prefix].handler = portOrPath; + assign(state.servernames[prefix], portOrPath, indexes); remoteHost = prefix; return true; } @@ -210,7 +255,7 @@ controllers.http = function (req, res, opts) { Object.keys(state.servernames).some(function (key) { //var prefix = appname + '.' + key; var prefix = key; - state.servernames[key].handler = portOrPath; + assign(state.servernames[key], portOrPath, indexes); remoteHost = prefix; return true; }); @@ -218,38 +263,36 @@ controllers.http = function (req, res, opts) { } state.config.servernames = state.servernames; saveConfig(function (err) { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ + res.send({ success: true , active: active , remote: remoteHost , local: portOrPath , saved: !err , module: 'http' - })); + }); }); }; -controllers.tcp = function (req, res, opts) { - if (!opts.body) { +controllers.tcp = function (req, res) { + if (!req.body) { res.statusCode = 422; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: { message: "module 'tcp' needs more arguments" } })); + res.send({ error: { message: "module 'tcp' needs more arguments" } }); return; } var active; - var remotePort = opts.body[1]; - var portOrPath = opts.body[0]; + var remotePort = req.body[1]; + var portOrPath = req.body[0]; // portnum if (remotePort) { if (!state.ports[remotePort]) { active = false; - return; + } else { + // forward-to port-or-module + // TODO with the connect event bug fixed, we should now be able to send files over tcp + state.ports[remotePort].handler = portOrPath; } - // forward-to port-or-module - // TODO we can't send files over tcp until we fix the connect event bug - state.ports[remotePort].handler = portOrPath; } else { if (!Object.keys(state.ports).some(function (key) { if (!state.ports[key].handler) { @@ -267,22 +310,20 @@ controllers.tcp = function (req, res, opts) { } state.config.ports = state.ports; saveConfig(function (err) { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ + res.send({ success: true , active: active , remote: remotePort , local: portOrPath , saved: !err , module: 'tcp' - })); + }); }); }; -controllers.ssh = function (req, res, opts) { - if (!opts.body) { +controllers.ssh = function (req, res) { + if (!req.body) { res.statusCode = 422; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({"error":{"message":"module 'ssh' needs more arguments"}})); + res.send({"error":{"message":"module 'ssh' needs more arguments"}}); return; } @@ -293,20 +334,20 @@ controllers.ssh = function (req, res, opts) { if (false !== local && !local) { local = 22; } - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ + res.send({ success: true , active: true , remote: Object.keys(state.config.ports)[0] , local: local , saved: !err , module: 'ssh' - })); + }); }); } - var sshAuto = opts.body[0]; - if (-1 !== [ 'false', 'none', 'off', 'disable' ].indexOf(sshAuto)) { + var rawSshAuto = req.body.port || req.body[0]; + var sshAuto = rawSshAuto; + if (-1 !== [ -1, 'false', 'none', 'off', 'disable' ].indexOf(sshAuto)) { state.config.sshAuto = false; sshSuccess(); return; @@ -319,81 +360,456 @@ controllers.ssh = function (req, res, opts) { sshAuto = parseInt(sshAuto, 10); if (!sshAuto || sshAuto <= 0 || sshAuto > 65535) { res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ error: { message: "bad ssh_auto option '" + opts.body[0] + "'" } })); + res.send({ error: { message: "bad ssh_auto option '" + rawSshAuto + "'" } }); return; } state.config.sshAuto = sshAuto; sshSuccess(); }; -function serveControlsHelper() { - controlServer = http.createServer(function (req, res) { - var opts = url.parse(req.url, true); - if (opts.query._body) { - try { - opts.body = JSON.parse(decodeURIComponent(opts.query._body, true)); - } catch(e) { - res.statusCode = 500; - res.end('{"error":{"message":"?_body={{bad_format}}"}}'); +controllers.relay = function (req, res) { + if (!req.body) { + res.statusCode = 422; + res.send({"error":{"message":"module \'relay\' needs more arguments"}}); + return; + } + + return urequestAsync(req.body).then(function (resp) { + resp = resp.toJSON(); + res.send(resp); + }); +}; +controllers._nonces = {}; +controllers._requireNonce = function (req, res, next) { + var nonce = req.jws && req.jws.header && req.jws.header.nonce; + var active = (Date.now() - controllers._nonces[nonce]) < (4 * 60 * 60 * 1000); + if (!active) { + // TODO proper headers and error message + res.send({ "error": "invalid or expired nonce", "error_code": "ENONCE" }); + return; + } + delete controllers._nonces[nonce]; + controllers._issueNonce(req, res); + next(); +}; +controllers._issueNonce = function (req, res) { + var nonce = toUrlSafe(crypto.randomBytes(16).toString('base64')); + // TODO associate with a TLS session + controllers._nonces[nonce] = Date.now(); + res.setHeader("Replay-Nonce", nonce); + return nonce; +}; +controllers.newNonce = function (req, res) { + res.statusCode = 200; + res.setHeader("Cache-Control", "max-age=0, no-cache, no-store"); + // TODO + //res.setHeader("Date", "Sun, 10 Mar 2019 08:04:45 GMT"); + // is this the expiration of the nonce itself? methinks maybe so + //res.setHeader("Expires", "Sun, 10 Mar 2019 08:04:45 GMT"); + // TODO use one of the registered domains + //var indexUrl = "https://acme-staging-v02.api.letsencrypt.org/index" + var port = (state.config.ipc && state.config.ipc.port || state._ipc.port || undefined); + var indexUrl = "http://localhost:" + port + "/index"; + res.setHeader("Link", "<" + indexUrl + ">;rel=\"index\""); + res.setHeader("Cache-Control", "max-age=0, no-cache, no-store"); + res.setHeader("Pragma", "no-cache"); + //res.setHeader("Strict-Transport-Security", "max-age=604800"); + res.setHeader("X-Frame-Options", "DENY"); + + controllers._issueNonce(req, res); + res.end(""); +}; +controllers.newAccount = function (req, res) { + controllers._requireNonce(req, res, function () { + // TODO clean up error messages to be similar to ACME + + // check if there's a public key + if (!req.jws || !req.jws.header.jwk) { + res.statusCode = 422; + res.send({ error: { message: "jws body was not present or could not be validated" } }); + return; + } + + // TODO mx record email validation + if (!Array.isArray(req.body.contact) || !req.body.contact.length && '127.0.0.1' !== req.connection.remoteAddress) { + // req.body.contact: [ 'mailto:email' ] + res.statusCode = 422; + res.send({ error: { message: "jws signed payload should contain a valid mailto:email in the contact array" } }); + return; + } + if (!req.body.termsOfServiceAgreed) { + // req.body.termsOfServiceAgreed: true + res.statusCode = 422; + res.send({ error: { message: "jws signed payload should have termsOfServiceAgreed: true" } }); + return; + } + + // We verify here regardless of whether or not it was verified before, + // because it needs to be signed by the presenter of the public key, + // not just a trusted key + return verifyJws(req.jws.header.jwk, req.jws).then(function (verified) { + if (!verified) { + res.statusCode = 422; + res.send({ error: { message: "jws body failed verification" } }); return; } - } - function listSuccess() { - var dumpy = { - servernames: state.servernames - , ports: state.ports - , ssh: state.config.sshAuto || 'disabled' - , code: 'CONFIG' - }; - if (state.otp) { - dumpy.device_pair_code = state.otp; - } + var jwk = req.jws.header.jwk; + return keypairs.thumbprint({ jwk: jwk }).then(function (thumb) { + // Note: we can get any number of account requests + // and these need to be stored for some space of time + // to await verification. + // we'll have to expire them somehow and prevent DoS - if (state._can_pair && state.config.email && !state.token) { - dumpy.code = "AWAIT_AUTH"; - dumpy.message = "Please run 'telebit init' to authenticate."; - } + // check if this account already exists + var account; + DB.accounts.some(function (acc) { + // TODO calculate thumbprint from jwk + // find a key with matching jwk + if (acc.thumb === thumb) { + account = acc; + return true; + } + // TODO ACME requires kid to be the account URL (STUPID!!!) + // rather than the key id (as decided by the key issuer) + // not sure if it's necessary to handle it that way though + }); - res.end(JSON.stringify(dumpy)); - } - - function getConfigOnly() { - var resp = JSON.parse(JSON.stringify(state.config)); - resp.version = pkg.version; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(resp)); - } - - // - // without proper config - // - function saveAndReport() { - console.log('[DEBUG] saveAndReport config write', confpath); - console.log(YAML.safeDump(snakeCopy(state.config))); - fs.writeFile(confpath, YAML.safeDump(snakeCopy(state.config)), function (err) { - if (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end('{"error":{"message":"Could not save config file after init: ' + err.message.replace(/"/g, "'") - + '.\nPerhaps check that the file exists and your user has permissions to write it?"}}'); - return; + var myBaseUrl = (req.connection.encrypted ? 'https' : 'http') + '://' + req.headers.host; + if (!account) { + // fail if onlyReturnExisting is not false + if (req.body.onlyReturnExisting) { + res.statusCode = 422; + res.send({ error: { message: "onlyReturnExisting is set, so there's nothing to do" } }); + return; + } + res.statusCode = 201; + account = {}; + account._id = toUrlSafe(crypto.randomBytes(16).toString('base64')); + // TODO be better about this + account.location = myBaseUrl + '/acme/accounts/' + account._id; + account.thumb = thumb; + account.pub = jwk; + account.contact = req.body.contact; + account.useragent = req.headers["user-agent"]; + DB.accounts.push(account); + state.config.accounts = DB.accounts; + saveConfig(function () {}); } - listSuccess(); + var result = { + status: 'valid' + , contact: account.contact // [ "mailto:john.doe@gmail.com" ], + , orders: account.location + '/orders' + // optional / off-spec + , id: account._id + , jwk: account.pub + /* + // I'm not sure if we have the real IP through telebit's network wrapper at this point + // TODO we also need to set X-Forwarded-Addr as a proxy + "initialIp": req.connection.remoteAddress, //"128.187.116.28", + "createdAt": (new Date()).toISOString(), // "2018-04-17T21:29:10.833305103Z", + */ + }; + res.setHeader('Location', account.location); + res.send(result); + /* + Cache-Control: max-age=0, no-cache, no-store + Content-Type: application/json + Expires: Tue, 17 Apr 2018 21:29:10 GMT + Link: ;rel="terms-of-service" + Location: https://acme-staging-v02.api.letsencrypt.org/acme/acct/5937234 + Pragma: no-cache + Replay-nonce: DKxX61imF38y_qkKvVcnWyo9oxQlHll0t9dMwGbkcxw + */ }); + }); + }); +}; +controllers.acmeAccounts = function (req, res) { + if (!req.jws || !req.jws.verified) { + res.statusCode = 400; + res.send({"error":{"message": "this type of requests must be encoded as a jws payload" + + " and signed by a known account holder"}}); + return; + } + var account; + var accountId = req.params[0]; + DB.accounts.some(function (acc) { + // TODO calculate thumbprint from jwk + // find a key with matching jwk + if (acc._id === accountId) { + account = acc; + return true; } + }); + // TODO check that the JWS matches the accountI + console.warn("[warn] account ID still acts as secret, should use JWS kid for verification"); + res.send(account); +}; - function initOrConfig() { - var conf = {}; - if (!opts.body) { - res.statusCode = 422; - res.end('{"error":{"message":"module \'init\' needs more arguments"}}'); +function jsonEggspress(req, res, next) { + /* + var opts = url.parse(req.url, true); + if (false && opts.query._body) { + try { + req.body = JSON.parse(decodeURIComponent(opts.query._body, true)); + } catch(e) { + res.statusCode = 500; + res.end('{"error":{"message":"?_body={{bad_format}}"}}'); + return; + } + } + */ + + var hasLength = req.headers['content-length'] > 0; + if (!hasLength && !req.headers['content-type']) { + next(); + return; + } + + var body = ''; + req.on('readable', function () { + var data; + while (true) { + data = req.read(); + if (!data) { break; } + body += data.toString(); + } + }); + req.on('end', function () { + try { + req.body = JSON.parse(body); + } catch(e) { + res.statusCode = 400; + res.send({"error":{"message":"POST body is not valid json"}}); + return; + } + next(); + }); +} + +function decodeJwt(jwt) { + var parts = jwt.split('.'); + var jws = { + protected: parts[0] + , payload: parts[0] + , signature: parts[2] //Buffer.from(parts[2], 'base64') + }; + jws.header = JSON.parse(Buffer.from(jws.protected, 'base64')); + jws.claims = JSON.parse(Buffer.from(jws.payload, 'base64')); + return jws; +} +function jwtEggspress(req, res, next) { + var jwt = (req.headers.authorization||'').replace(/Bearer /i, ''); + if (!jwt) { next(); return; } + + try { + req.jwt = decodeJwt(jwt); + } catch(e) { + // ignore + next(); + return; + } + if (!req.jwt.header.kid) { + res.send({ error: { message: "JWT must include a SHA thumbprint as the 'kid' (key id)" } }); + return; + } + + // TODO verify if possible + console.warn("[warn] JWT is not verified yet"); + // A failed JWS should cause a failed JWT + if (false !== req.trusted) { + req.trusted = true; + } + next(); +} + +// TODO switch to Keypairs.js / Keyfetch.js +function verifyJws(jwk, jws) { + return keypairs.export({ jwk: jwk }).then(function (pem) { + var alg = 'SHA' + jws.header.alg.replace(/[^\d]+/i, ''); + var sig = ecdsaAsn1SigToJwtSig(jws.header.alg, jws.signature); + return crypto + .createVerify(alg) + .update(jws.protected + '.' + jws.payload) + .verify(pem, sig, 'base64'); + }); +} + +function jwsEggspress(req, res, next) { + // Check to see if this looks like a JWS + // TODO check header application/jose+json ?? + if (!req.body || !(req.body.protected && req.body.payload && req.body.signature)) { + next(); + return; + } + + // Decode it a bit + req.jws = req.body; + req.jws.header = JSON.parse(Buffer.from(req.jws.protected, 'base64')); + req.body = Buffer.from(req.jws.payload, 'base64'); + if ('{'.charCodeAt(0) === req.body[0] || '['.charCodeAt(0) === req.body[0]) { + req.body = JSON.parse(req.body); + } + + var ua = req.headers['user-agent']; + var trusted = false; + var vjwk; + var pubs; + var kid = req.jws.header.kid; + var p = Promise.resolve(); + if (!kid && !req.jws.header.jwk) { + res.send({ error: { message: "jws protected header must include either 'kid' or 'jwk'" } }); + return; + } + if (req.jws.header.jwk) { + if (kid) { + res.send({ error: { message: "jws protected header must not include both 'kid' and 'jwk'" } }); + return; + } + kid = req.jws.header.jwk.kid; + p = Keypairs.thumbprint({ jwk: req.jws.header.jwk }).then(function (thumb) { + if (kid && kid !== thumb) { + res.send({ error: { message: "jwk included 'kid' for key id, but it did not match the key's thumbprint" } }); return; } + kid = thumb; + req.jws.header.jwk.kid = thumb; + }); + } + + // Check if this is a key we already trust + DB.pubs.some(function (jwk) { + if (jwk.kid === kid) { + trusted = true; + vjwk = jwk; + return true; + } + }); + + // Check for CLI or Browser User-Agent + // (both should connect as part of setup) + if (/Telebit/i.test(ua) && !/Mozilla/i.test(ua)) { + pubs = DB.pubs.filter(function (jwk) { + if (/Telebit/i.test(jwk.useragent) && !/Mozilla/i.test(jwk.useragent)) { + return true; + } + }); + } else { + pubs = DB.pubs.filter(function (jwk) { + if (!/Telebit/i.test(jwk.useragent) || /Mozilla/i.test(jwk.useragent)) { + return true; + } + }); + } + + p.then(function () { + // Check if there aren't any keys that we trust + // and this has signed itself, then make it a key we trust + // (TODO: move this all to the new account function) + if (0 === pubs.length) { trusted = true; } + if (!vjwk) { vjwk = req.jws.header.jwk; } + // Don't verify if it can't be verified + if (!vjwk) { return null; } + + // Run the verification + return p.then(function () { + return verifyJws(vjwk, req.jws).then(function (verified) { + if (true !== verified) { return null; } + + // Mark as verified + req.jws.verified = verified; + req.jws.trusted = trusted; + vjwk.useragent = ua; + + // (double check) DO NOT save if there are existing pubs + if (0 !== pubs.length) { return null; } + + DB.pubs.push(vjwk); + return keystore.set(vjwk.kid + PUBEXT, vjwk); + }); + }); + }).then(function () { + // a failed JWT should cause a failed JWS + if (false !== req.trusted) { + req.trusted = req.jws.trusted; + } + next(); + }); +} + +function handleApi() { + var app = eggspress(); + + app.use('/', jwtEggspress); + app.use('/', jsonEggspress); + app.use('/', jwsEggspress); + app.use('/', function (req, res, next) { + if (req.jwt) { + console.log('jwt', req.jwt); + } else if (req.jws) { + console.log('jws', req.jws); + console.log('body', req.body); + } + next(); + }); + + function listSuccess(req, res) { + var dumpy = { + servernames: state.servernames + , ports: state.ports + , ssh: state.config.sshAuto || 'disabled' + , code: 'CONFIG' + }; + if (state.otp) { + dumpy.device_pair_code = state.otp; + } + + if (state._can_pair && state.config.email && !state.token) { + dumpy.code = "AWAIT_AUTH"; + dumpy.message = "Please run 'telebit init' to authenticate."; + } + + res.send(dumpy); + } + + function getConfigOnly(req, res) { + var resp = JSON.parse(JSON.stringify(state.config)); + resp.version = pkg.version; + resp._otp = state.otp; + res.send(resp); + } + + // + // without proper config + // + function saveAndReport(req, res) { + console.log('[DEBUG] saveAndReport config write', confpath); + console.log(YAML.safeDump(snakeCopy(state.config))); + fs.writeFile(confpath, YAML.safeDump(snakeCopy(state.config)), function (err) { + if (err) { + res.statusCode = 500; + res.send({"error":{"message":"Could not save config file after init: " + err.message.replace(/"/g, "'") + + ".\nPerhaps check that the file exists and your user has permissions to write it?"}}); + return; + } + + listSuccess(req, res); + }); + } + + function initOrConfig(req, res) { + var conf = {}; + if (!req.body) { + res.statusCode = 422; + res.send({"error":{"message":"module 'init' needs more arguments"}}); + return; + } + + if (Array.isArray(req.body)) { // relay, email, agree_tos, servernames, ports // - opts.body.forEach(function (opt) { + req.body.forEach(function (opt) { var parts = opt.split(/:/); if ('true' === parts[1]) { parts[1] = true; @@ -406,249 +822,318 @@ function serveControlsHelper() { } conf[parts[0]] = parts[1]; }); - - // TODO camelCase query - state.config.email = conf.email || state.config.email || ''; - if ('undefined' !== typeof conf.agreeTos - || 'undefined' !== typeof conf.agreeTos ) { - state.config.agreeTos = conf.agreeTos || conf.agree_tos; - } - state.otp = conf._otp; // this should only be done on the client side - state.config.relay = conf.relay || state.config.relay || ''; - console.log(); - console.log('conf.token', typeof conf.token, conf.token); - console.log('state.config.token', typeof state.config.token, state.config.token); - state.config.token = conf.token || state.config.token || null; - state.config.secret = conf.secret || state.config.secret || null; - state.pretoken = conf.pretoken || state.config.pretoken || null; - if (state.secret) { - console.log('state.secret'); - state.token = common.signToken(state); - } - if (!state.token) { - console.log('!state.token'); - state.token = conf._token; - } - console.log(); - console.log('JSON.stringify(conf)'); - console.log(JSON.stringify(conf)); - console.log(); - console.log('JSON.stringify(state)'); - console.log(JSON.stringify(state)); - console.log(); - if ('undefined' !== typeof conf.newsletter) { - state.config.newsletter = conf.newsletter; - } - if ('undefined' !== typeof conf.communityMember - || 'undefined' !== typeof conf.community_member) { - state.config.communityMember = conf.communityMember || conf.community_member; - } - if ('undefined' !== typeof conf.telemetry) { - state.config.telemetry = conf.telemetry; - } - if (conf._servernames) { - (conf._servernames||'').split(/,/g).forEach(function (key) { - if (!state.config.servernames[key]) { - state.config.servernames[key] = { sub: undefined }; - } - }); - } - if (conf._ports) { - (conf._ports||'').split(/,/g).forEach(function (key) { - if (!state.config.ports[key]) { - state.config.ports[key] = {}; - } - }); - } - - if (!state.config.relay || !state.config.email || !state.config.agreeTos) { - console.warn('missing config'); - res.statusCode = 400; - - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: { - code: "E_INIT" - , message: "Missing important config file params" - , _params: JSON.stringify(conf) - , _config: JSON.stringify(state.config) - , _body: JSON.stringify(opts.body) - } - })); - return; - } - - // init also means enable - delete state.config.disable; - safeStartTelebitRemote(true).then(saveAndReport).catch(handleError); + } else { + conf = req.body; } - function restart() { - console.info("[telebitd.js] server closing..."); - state.keepAlive.state = false; - if (myRemote) { - myRemote.end(); - myRemote.on('end', respondAndClose); - // failsafe - setTimeout(function () { - console.info("[telebitd.js] closing too slowly, force quit"); - respondAndClose(); - }, 5 * 1000); - } else { - respondAndClose(); - } + conf = camelCopy(conf); - function respondAndClose() { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ success: true })); - controlServer.close(function () { - console.info("[telebitd.js] server closed"); - setTimeout(function () { - // system daemon will restart the process - process.exit(22); // use non-success exit code - }, 100); - }); + // TODO deep merge + // greenlock config + if (!state.config.greenlock) { state.config.greenlock = {}; } + if (conf.greenlock) { + if ('undefined' !== typeof conf.greenlock.agree) { + state.config.greenlock.agree = conf.greenlock.agree; } + if (conf.greenlock.server) { state.config.greenlock.server = conf.greenlock.server; } + if (conf.greenlock.version) { state.config.greenlock.version = conf.greenlock.version; } } - function invalidConfig() { + // main config + if (conf.email) { state.config.email = conf.email; } + if (conf.relay) { state.config.relay = conf.relay; } + if (conf.token) { state.config.token = conf.token; } + if (conf.secret) { state.config.secret = conf.secret; } + if ('undefined' !== typeof conf.agreeTos) { + state.config.agreeTos = conf.agreeTos; + } + + // to state + if (conf.pretoken) { state.pretoken = conf.pretoken; } + if (conf._otp) { + state.otp = conf._otp; // TODO should this only be done on the client side? + delete conf._otp; + } + + console.log(); + console.log('conf.token', typeof conf.token, conf.token); + console.log('state.config.token', typeof state.config.token, state.config.token); + + if (state.secret) { console.log('state.secret'); state.token = common.signToken(state); } + if (!state.token) { console.log('!state.token'); state.token = conf._token; } + + console.log(); + console.log('JSON.stringify(conf)'); + console.log(JSON.stringify(conf)); + console.log(); + console.log('JSON.stringify(state)'); + console.log(JSON.stringify(state)); + console.log(); + if ('undefined' !== typeof conf.newsletter) { + state.config.newsletter = conf.newsletter; + } + if ('undefined' !== typeof conf.communityMember + || 'undefined' !== typeof conf.community_member) { + state.config.communityMember = conf.communityMember || conf.community_member; + } + if ('undefined' !== typeof conf.telemetry) { + state.config.telemetry = conf.telemetry; + } + if (conf._servernames) { + (conf._servernames||'').split(/,/g).forEach(function (key) { + if (!state.config.servernames[key]) { + state.config.servernames[key] = { sub: undefined }; + } + }); + } + if (conf._ports) { + (conf._ports||'').split(/,/g).forEach(function (key) { + if (!state.config.ports[key]) { + state.config.ports[key] = {}; + } + }); + } + + if (!state.config.relay || !state.config.email || !state.config.agreeTos) { + console.warn('missing config'); res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: { code: "E_CONFIG", message: "Invalid config file. Please run 'telebit init'" } - })); - } - function saveAndCommit() { - state.config.servernames = state.servernames; - state.config.ports = state.ports; - fs.writeFile(confpath, YAML.safeDump(snakeCopy(state.config)), function (err) { - if (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - "error":{"message":"Could not save config file. Perhaps you're not running as root?"} - })); - return; + res.send({ + error: { + code: "E_INIT" + , message: "Missing important config file params" + , _params: JSON.stringify(conf) + , _config: JSON.stringify(state.config) + , _body: JSON.stringify(req.body) } - listSuccess(); }); - } - - function handleError(err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: { message: err.message, code: err.code } - })); - } - - function enable() { - delete state.config.disable;// = undefined; - state.keepAlive.state = true; - - fs.writeFile(confpath, YAML.safeDump(snakeCopy(state.config)), function (err) { - if (err) { - err.message = "Could not save config file. Perhaps you're user doesn't have permission?"; - handleError(err); - return; - } - // TODO XXX myRemote.active - if (myRemote) { - listSuccess(); - return; - } - safeStartTelebitRemote(true).then(listSuccess).catch(handleError); - }); - } - - function disable() { - state.config.disable = true; - state.keepAlive.state = false; - - if (myRemote) { myRemote.end(); myRemote = null; } - fs.writeFile(confpath, YAML.safeDump(snakeCopy(state.config)), function (err) { - res.setHeader('Content-Type', 'application/json'); - if (err) { - err.message = "Could not save config file. Perhaps you're user doesn't have permission?"; - handleError(err); - return; - } - res.end('{"success":true}'); - }); - } - - function getStatus() { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify( - { status: (state.config.disable ? 'disabled' : 'enabled') - , ready: ((state.config.relay && (state.config.token || state.config.agreeTos)) ? true : false) - , active: !!myRemote - , connected: 'maybe (todo)' - , version: pkg.version - , servernames: state.servernames - } - )); - } - - if (/\b(config)\b/.test(opts.pathname) && /get/i.test(req.method)) { - getConfigOnly(); return; } - if (/\b(init|config)\b/.test(opts.pathname)) { - initOrConfig(); - return; + + // init also means enable + delete state.config.disable; + safeStartTelebitRemote(true).then(function () { + saveAndReport(req, res); + }).catch(handleError); + } + + function restart(req, res) { + console.info("[telebitd.js] server closing..."); + state.keepAlive.state = false; + if (myRemote) { + myRemote.end(); + myRemote.on('end', respondAndClose); + // failsafe + setTimeout(function () { + console.info("[telebitd.js] closing too slowly, force quit"); + respondAndClose(); + }, 5 * 1000); + } else { + respondAndClose(); } - if (/restart/.test(opts.pathname)) { - restart(); - return; + + function respondAndClose() { + res.send({ success: true }); + controlServer.close(function () { + console.info("[telebitd.js] server closed"); + setTimeout(function () { + // system daemon will restart the process + process.exit(22); // use non-success exit code + }, 100); + }); } + } + + function mustHaveValidConfig(req, res, next) { // // Check for proper config // - if (!state.config.relay || !state.config.email || !state.config.agreeTos) { - invalidConfig(); - return; - } - // - // With proper config - // - if (/http/.test(opts.pathname)) { - controllers.http(req, res, opts); - return; - } - if (/tcp/.test(opts.pathname)) { - controllers.tcp(req, res, opts); - return; - } - if (/save|commit/.test(opts.pathname)) { - saveAndCommit(); - return; - } - if (/ssh/.test(opts.pathname)) { - controllers.ssh(req, res, opts); - return; - } - if (/enable/.test(opts.pathname)) { - enable(); - return; - } - if (/disable/.test(opts.pathname)) { - disable(); - return; - } - if (/status/.test(opts.pathname)) { - getStatus(); - return; - } - if (/list/.test(opts.pathname)) { - listSuccess(); + if (state.config.relay && state.config.email && state.config.agreeTos) { + next(); return; } - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({"error":{"message":"unrecognized rpc"}})); + res.statusCode = 400; + res.send({ + error: { code: "E_CONFIG", message: "Invalid config file. Please run 'telebit init'" } + }); + } + + function saveAndCommit(req, res) { + state.config.servernames = state.servernames; + state.config.ports = state.ports; + fs.writeFile(confpath, YAML.safeDump(snakeCopy(state.config)), function (err) { + if (err) { + res.statusCode = 500; + res.send({ + "error":{"message":"Could not save config file. Perhaps you're not running as root?"} + }); + return; + } + listSuccess(req, res); + }); + } + + function handleError(err, req, res) { + res.statusCode = 500; + res.send({ + error: { message: err.message, code: err.code } + }); + } + + function enable(req, res) { + delete state.config.disable;// = undefined; + state.keepAlive.state = true; + + fs.writeFile(confpath, YAML.safeDump(snakeCopy(state.config)), function (err) { + if (err) { + err.message = "Could not save config file. Perhaps you're user doesn't have permission?"; + handleError(err, req, res); + return; + } + // TODO XXX myRemote.active + if (myRemote) { + listSuccess(req, res); + return; + } + safeStartTelebitRemote(true).then(function () { + listSuccess(req, res); + }).catch(function () { + handleError(err, req, res); + }); + }); + } + + function disable(req, res) { + state.config.disable = true; + state.keepAlive.state = false; + + if (myRemote) { myRemote.end(); myRemote = null; } + fs.writeFile(confpath, YAML.safeDump(snakeCopy(state.config)), function (err) { + if (err) { + err.message = "Could not save config file. Perhaps you're user doesn't have permission?"; + handleError(err); + return; + } + res.send({"success":true}); + }); + } + + function getStatus(req, res) { + var now = Date.now(); + require('../lib/ssh.js').checkSecurity().then(function (ssh) { + res.send( + { module: 'status' + , version: pkg.version + , port: (state.config.ipc && state.config.ipc.port || state._ipc.port || undefined) + , enabled: !state.config.disable + , active: !!myRemote + , initialized: (state.config.relay && state.config.token && state.config.agreeTos) ? true : false + , connected: isConnected + //, proctime: Math.round(process.uptime() * 1000) + , uptime: now - startTime + , runtime: isConnected && connectTimes.length && (now - connectTimes[0]) || 0 + , reconnects: connectTimes.length + , servernames: state.servernames + , ssh: state.config.sshAuto + , ssh_permit_root_login: ssh.permit_root_login + , ssh_password_authentication: ssh.password_authentication + , ssh_requests_password: ssh.requests_password + } + ); + }); + } + + // TODO turn strings into regexes to match beginnings + app.get('/.well-known/openid-configuration', function (req, res) { + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Expose-Headers", "Link, Replay-Nonce, Location"); + res.setHeader("Access-Control-Max-Age", "86400"); + if ('OPTIONS' === req.method) { res.end(); return; } + res.send({ + jwks_uri: 'http://localhost/.well-known/jwks.json' + , acme_uri: 'http://localhost/acme/directory' + }); }); + app.use('/acme', function acmeCors(req, res, next) { + // Taken from New-Nonce + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Expose-Headers", "Link, Replay-Nonce, Location"); + res.setHeader("Access-Control-Max-Age", "86400"); + if ('OPTIONS' === req.method) { res.end(); return; } + next(); + }); + app.get('/acme/directory', function (req, res) { + var myBaseUrl = (req.connection.encrypted ? 'https' : 'http') + '://' + req.headers.host; + res.send({ + 'newNonce': '/acme/new-nonce' + , 'newAccount': '/acme/new-acct' + // TODO link to the terms that the user selects + , 'meta': { 'termsOfService': myBaseUrl + '/acme/terms.html' } + }); + }); + app.head('/acme/new-nonce', controllers.newNonce); + app.get('/acme/new-nonce', controllers.newNonce); + app.post('/acme/new-acct', controllers.newAccount); + function mustTrust(req, res, next) { + // TODO public routes should be explicitly marked + // trusted should be the default + if (!req.trusted) { + res.statusCode = 400; + res.send({"error":{"message": "this type of requests must be encoded as a jws payload" + + " and signed by a trusted account holder"}}); + return; + } + + next(); + } + // TODO convert /acme/accounts/:account_id into a regex + app.get(/^\/acme\/accounts\/([\w]+)/, controllers.acmeAccounts); + // POST-as-GET + app.post(/^\/acme\/accounts\/([\w]+)/, controllers.acmeAccounts); + app.use(/\b(relay)\b/, mustTrust, controllers.relay); + app.get(/\b(config)\b/, mustTrust, getConfigOnly); + app.use(/\b(init|config)\b/, mustTrust, initOrConfig); + app.use(/\b(restart)\b/, mustTrust, restart); + + // Position is important with eggspress + // This should stay here, right before the other methods + app.use('/', mustHaveValidConfig); + + // + // With proper config + // + app.use(/\b(http)\b/, mustTrust, controllers.http); + app.use(/\b(tcp)\b/, mustTrust, controllers.tcp); + app.use(/\b(save|commit)\b/, mustTrust, saveAndCommit); + app.use(/\b(ssh)\b/, mustTrust, controllers.ssh); + app.use(/\b(enable)\b/, mustTrust, enable); + app.use(/\b(disable)\b/, mustTrust, disable); + app.use(/\b(status)\b/, mustTrust, getStatus); + app.use(/\b(list)\b/, mustTrust, listSuccess); + app.use('/', function (req, res) { + res.send({"error":{"message":"unrecognized rpc: [" + req.method + "] " + req.url + "\n" + + JSON.stringify(req.headers) + "\n" + + JSON.stringify(req.body) + "\n" + }}); + }); + + return app; +} + +function serveControlsHelper() { + var app = eggspress(); + var serveStatic = require('serve-static')(path.join(__dirname, '../lib/admin/')); + var apiHandler = handleApi(); + + app.use('/rpc/', apiHandler); + app.use('/api/', apiHandler); + app.use('/acme/', apiHandler); + app.use('/', serveStatic); + + controlServer = http.createServer(app); if (fs.existsSync(state._ipc.path)) { fs.unlinkSync(state._ipc.path); @@ -661,15 +1146,30 @@ function serveControlsHelper() { , readableAll: true , exclusive: false }; + if (!state.config.ipc) { + state.config.ipc = {}; + } + if (!state.config.ipc.path) { + state.config.ipc.path = path.dirname(state._ipc.path); + } + require('mkdirp').sync(state.config.ipc.path); + if (!state.config.ipc.type) { + state.config.ipc.type = 'port'; + } + var portFile = path.join(state.config.ipc.path, 'telebit.port'); + if (fs.existsSync(portFile)) { + state._ipc.port = parseInt(fs.readFileSync(portFile, 'utf8').trim(), 10); + } + if ('socket' === state._ipc.type) { require('mkdirp').sync(path.dirname(state._ipc.path)); } // https://nodejs.org/api/net.html#net_server_listen_options_callback // path is ignore if port is defined // https://git.coolaj86.com/coolaj86/telebit.js/issues/23#issuecomment-326 - if (state._ipc.port) { + if ('port' === state.config.ipc.type) { serverOpts.host = 'localhost'; - serverOpts.port = state._ipc.port; + serverOpts.port = state._ipc.port || 0; } else { serverOpts.path = state._ipc.path; } @@ -682,6 +1182,23 @@ function serveControlsHelper() { //console.log(this.address()); console.info("[info] Listening for commands on", address); }); + controlServer.on('error', function (err) { + if ('EADDRINUSE' === err.code) { + try { + fs.unlinkSync(portFile); + } catch(e) { + // nada + } + setTimeout(function () { + console.log("Could not start control server (%s), trying again...", err.code); + console.log(portFile); + console.log(serverOpts); + serveControlsHelper(); + }, 1000); + return; + } + console.error('failed to start c&c server:', err); + }); } function serveControls() { @@ -753,6 +1270,7 @@ function parseConfig(err, text) { } state.config = camelCopy(state.config || {}) || {}; + DB.accounts = state.config.accounts || []; run(); @@ -927,6 +1445,8 @@ function rawStartTelebitRemote(keepAlive) { } function onConnect() { + isConnected = true; + connectTimes.unshift(Date.now()); console.info('[connect] relay established'); myRemote.removeListener('error', onConnectError); myRemote.once('error', function (err) { @@ -944,6 +1464,7 @@ function rawStartTelebitRemote(keepAlive) { function onConnectError(err) { myRemote = null; + isConnected = false; if (handleError(err, 'onConnectError')) { if (!keepAlive.state) { reject(err); @@ -959,6 +1480,7 @@ function rawStartTelebitRemote(keepAlive) { } function retryLoop() { + isConnected = false; console.warn('[Warn] disconnected. Will retry?', keepAlive.state); if (keepAlive.state) { safeReload(10 * 1000).then(resolve).catch(reject); @@ -1101,15 +1623,19 @@ state.handlers = { return; } state.token = opts.jwt || opts.access_token; + // TODO don't put token in config state.config.token = opts.jwt || opts.access_token; - console.info("Updating '" + tokenpath + "' with new token:"); + console.info("Placing new token in keystore."); try { - fs.writeFileSync(tokenpath, opts.jwt); fs.writeFileSync(confpath, YAML.safeDump(snakeCopy(state.config))); } catch (e) { console.error("Token not saved:"); console.error(e); } + return keystore.set("access_token.jwt", opts.jwt || opts.access_token).catch(function (e) { + console.error("Token not saved:"); + console.error(e); + }); } }; @@ -1146,6 +1672,109 @@ state.net = state.net || { } }; -fs.readFile(confpath, 'utf8', parseConfig); +var DB = {}; +DB.pubs = []; +DB.accounts = []; +var token; +var tokenname = "access_token.jwt"; +try { + // backwards-compatibility shim + var tokenpath = path.join(path.dirname(state._confpath), 'access_token.txt'); + token = fs.readFileSync(tokenpath, 'ascii').trim(); + keystore.set(tokenname, token).then(onKeystore).catch(function (err) { + console.error('keystore failure:'); + console.error(err); + }); +} catch(e) { onKeystore(); } +function onKeystore() { + return keystore.all().then(function (list) { + var key; + list.forEach(function (el) { + // find key + if (KEYEXT === el.account.slice(-KEYEXT.length) + && el.password.kty && el.password.kid) { + key = el.password; + return; + } + // find token + if (tokenname === el.account) { + token = el.password; + return; + } + + // find trusted public keys + // (if we sign these we could probably just store them to the fs, + // but we do want some way to know that they weren't just willy-nilly + // added to the fs my any old program) + if (PUBEXT === el.account.slice(-PUBEXT.length)) { + // pre-parsed + DB.pubs.push(el.password); + return; + } + + console.log("unrecognized password: %s", el.account); + }); + + if (key) { + state.key = key; + state.pub = keypairs.neuter({ jwk: key }); + fs.readFile(confpath, 'utf8', parseConfig); + return; + } + + return keypairs.generate().then(function (pair) { + var jwk = pair.private; + return keypairs.thumbprint({ jwk: jwk }).then(function (kid) { + jwk.kid = kid; + return keystore.set(kid + KEYEXT, jwk).then(function () { + var size = (jwk.crv || Buffer.from(jwk.n, 'base64').byteLength * 8); + console.info("Generated new %s %s private key with thumbprint %s", jwk.kty, size, kid); + state.key = jwk; + fs.readFile(confpath, 'utf8', parseConfig); + }); + }); + }); + }); +} }()); + +function ecdsaAsn1SigToJwtSig(alg, b64sig) { + // ECDSA JWT signatures differ from "normal" ECDSA signatures + // https://tools.ietf.org/html/rfc7518#section-3.4 + if (!/^ES/i.test(alg)) { return b64sig; } + + var bufsig = Buffer.from(b64sig, 'base64'); + var hlen = bufsig.byteLength / 2; // should be even + var r = bufsig.slice(0, hlen); + var s = bufsig.slice(hlen); + // unpad positive ints less than 32 bytes wide + while (!r[0]) { r = r.slice(1); } + while (!s[0]) { s = s.slice(1); } + // pad (or re-pad) ambiguously non-negative BigInts to 33 bytes wide + if (0x80 & r[0]) { r = Buffer.concat([Buffer.from([0]), r]); } + if (0x80 & s[0]) { s = Buffer.concat([Buffer.from([0]), s]); } + + var len = 2 + r.byteLength + 2 + s.byteLength; + var head = [0x30]; + // hard code 0x80 + 1 because it won't be longer than + // two SHA512 plus two pad bytes (130 bytes <= 256) + if (len >= 0x80) { head.push(0x81); } + head.push(len); + + var buf = Buffer.concat([ + Buffer.from(head) + , Buffer.from([0x02, r.byteLength]), r + , Buffer.from([0x02, s.byteLength]), s + ]); + + return toUrlSafe(buf.toString('base64')); +} + +function toUrlSafe(b64) { + return b64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + ; +} diff --git a/lib/admin/documentation/index.html b/lib/admin/documentation/index.html new file mode 100644 index 0000000..88cb646 --- /dev/null +++ b/lib/admin/documentation/index.html @@ -0,0 +1,60 @@ + + + + Telebit Documentation + + +
+

Telebit (Remote) Documentation

+ +
+

GET /api/config

+
{{ config }}
+
+ +
+

GET /api/status

+
{{ status }}
+
+ +
+

POST /api/init

+
+ + + +
+ + +
+ + +
+ +
+
{{ init }}
+
+ +
+

POST /api/http

+
{{ http }}
+
+ +
+

POST /api/tcp

+
{{ tcp }}
+
+ +
+

POST /api/ssh

+
{{ ssh }}
+
+ +
+ + + + + diff --git a/lib/admin/index.html b/lib/admin/index.html new file mode 100644 index 0000000..0f91adf --- /dev/null +++ b/lib/admin/index.html @@ -0,0 +1,235 @@ + + + + Telebit Setup + + + + +
+

Telebit (Remote) Setup v{{ config.version }}

+ +
+ {{ views.flash.error }} +
+ +
+ Loading... +
+ +
+

Create Account

+
+ + + +
+ + +
+ + +
+ + + + + +

+ You'll receive a friendly note now and then in addition to the important updates. +

+

+ You'll only receive updates that we believe will be of the most value to you, and the required updates. +

+

+ You'll only receive security updates, transactional and account-related messages, and legal notices. +

+
+ +
Advanced + + + +
+ + +
+
+ + + +
+ + +
+
+ +
+ + + +
+
{{ init }}
+
+ +
+

Advanced Setup for {{ init.relay }}

+
+ + + +
+ + +
+ (comma separated list of domains to use for http, tls, https, etc) +
+ +
+ + +
+ (comman separated list of ports, excluding 80 and 443, typically port over 1024) +
+ +
+ + +
+ + + +
+
{{ init }}
+
+ +
+

{{ init.otp }}

+
+ +
+ http://localhost:{{ status.port }} +
+
+ +
+ + + +
+ +
+ SSH: + {{ status.ssh }} + + + +
+
+
SSH is currently running
+
SSH is not currently running
+
+
Password Authentication is NOT disabled. + Please consider updating your sshd_config and restarting ssh. +
{{ status }}
+
+
Key-Only Authentication is enabled :)
+
+
+ Important: Accessing this device with other SSH clients: +
+ In order to use your other ssh clients with telebit you will need to put them into + ssh+https mode. + + We recommend downloading sclient + to do so, because it makes it as simple as adding -o ProxyCommand="sclient %h" to your + ssh command to enable ssh+https: +
ssh -o ProxyCommand="sclient %h" {{ newHttp.name }}
+
+ However, most clients can also use openssl s_client, which does the same thing, but is + more difficult to remember: +
proxy_cmd='openssl s_client -connect %h:443 -servername %h -quiet'
+ssh -o ProxyCommand="$proxy_cmd" hot-skunk-45.telebit.io
+
+
+ +
+ Path Hosting: +
    +
  • +
    + {{ domain.name }} + + + +
    +
  • +
+
+ + + + +
+
+
+ +
+ Port Forwarding: +
    +
  • +
    + {{ domain.name }} + + + +
    +
  • +
+
+ + + + +
+
+ +
+ Uptime: {{ statusUptime }} +
+ Runtime: {{ statusRuntime }} +
+ Reconnects: {{ status.reconnects }} + +
Advanced + + +
+
+ +
{{ status }}
+
+
+ +
+ + + + + + + + diff --git a/lib/admin/js/app.js b/lib/admin/js/app.js new file mode 100644 index 0000000..be507a1 --- /dev/null +++ b/lib/admin/js/app.js @@ -0,0 +1,608 @@ +;(function () { +'use strict'; + +var Vue = window.Vue; +var Telebit = window.TELEBIT; +var Keypairs = window.Keypairs; +var ACME = window.ACME; +var api = {}; + +/* +function safeFetch(url, opts) { + var controller = new AbortController(); + var tok = setTimeout(function () { + controller.abort(); + }, 4000); + if (!opts) { + opts = {}; + } + opts.signal = controller.signal; + return window.fetch(url, opts).finally(function () { + clearTimeout(tok); + }); +} +*/ + +api.config = function apiConfig() { + return Telebit.reqLocalAsync({ + method: "GET" + , url: "/api/config" + , key: api._key + }).then(function (resp) { + var json = resp.body; + appData.config = json; + return json; + }); +}; +api.status = function apiStatus() { + return Telebit.reqLocalAsync({ + method: "GET" + , url: "/api/status" + , key: api._key + }).then(function (resp) { + var json = resp.body; + return json; + }); +}; +api.http = function apiHttp(o) { + var opts = { + method: "POST" + , url: "/api/http" + , headers: { 'Content-Type': 'application/json' } + , json: { name: o.name, handler: o.handler, indexes: o.indexes } + , key: api._key + }; + return Telebit.reqLocalAsync(opts).then(function (resp) { + var json = resp.body; + appData.initResult = json; + return json; + }).catch(function (err) { + window.alert("Error: [init] " + (err.message || JSON.stringify(err, null, 2))); + }); +}; +api.ssh = function apiSsh(port) { + var opts = { + method: "POST" + , url: "/api/ssh" + , headers: { 'Content-Type': 'application/json' } + , json: { port: port } + , key: api._key + }; + return Telebit.reqLocalAsync(opts).then(function (resp) { + var json = resp.body; + appData.initResult = json; + return json; + }).catch(function (err) { + window.alert("Error: [init] " + (err.message || JSON.stringify(err, null, 2))); + }); +}; +api.enable = function apiEnable() { + var opts = { + method: "POST" + , url: "/api/enable" + //, headers: { 'Content-Type': 'application/json' } + , key: api._key + }; + return Telebit.reqLocalAsync(opts).then(function (resp) { + var json = resp.body; + console.log('enable', json); + return json; + }).catch(function (err) { + window.alert("Error: [enable] " + (err.message || JSON.stringify(err, null, 2))); + }); +}; +api.disable = function apiDisable() { + var opts = { + method: "POST" + , url: "/api/disable" + //, headers: { 'Content-Type': 'application/json' } + , key: api._key + }; + return Telebit.reqLocalAsync(opts).then(function (resp) { + var json = resp.body; + console.log('disable', json); + return json; + }).catch(function (err) { + window.alert("Error: [disable] " + (err.message || JSON.stringify(err, null, 2))); + }); +}; + +function showOtp(otp, pollUrl) { + localStorage.setItem('poll_url', pollUrl); + telebitState.pollUrl = pollUrl; + appData.init.otp = otp; + changeState('otp'); +} +function doConfigure() { + if (telebitState.dir.pair_request) { + telebitState._can_pair = true; + } + + // + // Read config from form + // + + // Create Empty Config, If Necessary + if (!telebitState.config) { telebitState.config = {}; } + if (!telebitState.config.greenlock) { telebitState.config.greenlock = {}; } + + // Populate Config + if (appData.init.teletos && appData.init.letos) { telebitState.config.agreeTos = true; } + if (appData.init.relay) { telebitState.config.relay = appData.init.relay; } + if (appData.init.email) { telebitState.config.email = appData.init.email; } + if ('undefined' !== typeof appData.init.letos) { telebitState.config.greenlock.agree = appData.init.letos; } + if ('newsletter' === appData.init.notifications) { + telebitState.config.newsletter = true; telebitState.config.communityMember = true; + } + if ('important' === appData.init.notifications) { telebitState.config.communityMember = true; } + if (appData.init.acmeVersion) { telebitState.config.greenlock.version = appData.init.acmeVersion; } + if (appData.init.acmeServer) { telebitState.config.greenlock.server = appData.init.acmeServer; } + + // Temporary State + telebitState._otp = Telebit.otp(); + appData.init.otp = telebitState._otp; + + return Telebit.authorize(telebitState, showOtp).then(function () { + return changeState('status'); + }); +} + +// TODO test for internet connectivity (and telebit connectivity) +var DEFAULT_RELAY = 'telebit.cloud'; +var BETA_RELAY = 'telebit.ppl.family'; +var TELEBIT_RELAYS = [ + DEFAULT_RELAY +, BETA_RELAY +]; +var PRODUCTION_ACME = 'https://acme-v02.api.letsencrypt.org/directory'; +var STAGING_ACME = 'https://acme-staging-v02.api.letsencrypt.org/directory'; +var appData = { + config: {} +, status: {} +, init: { + teletos: true + , letos: true + , notifications: "important" + , relay: DEFAULT_RELAY + , telemetry: true + , acmeServer: PRODUCTION_ACME + } +, state: {} +, views: { + flash: { + error: "" + } + , section: { + loading: true + , setup: false + , advanced: false + , otp: false + , status: false + } + } +, newHttp: {} +}; +var telebitState = {}; +var appMethods = { + initialize: function () { + console.log("call initialize"); + return requestAccountHelper().then(function (/*key*/) { + if (!appData.init.relay) { + appData.init.relay = DEFAULT_RELAY; + } + appData.init.relay = appData.init.relay.toLowerCase(); + telebitState = { relay: appData.init.relay }; + + return Telebit.api.directory(telebitState).then(function (dir) { + if (!dir.api_host) { + window.alert("Error: '" + telebitState.relay + "' does not appear to be a valid telebit service"); + return; + } + + telebitState.dir = dir; + + // If it's one of the well-known relays + if (-1 !== TELEBIT_RELAYS.indexOf(appData.init.relay)) { + return doConfigure(); + } else { + changeState('advanced'); + } + }).catch(function (err) { + console.error(err); + window.alert("Error: [initialize] " + (err.message || JSON.stringify(err, null, 2))); + }); + }); + } +, advance: function () { + return doConfigure(); + } +, productionAcme: function () { + console.log("prod acme:"); + appData.init.acmeServer = PRODUCTION_ACME; + console.log(appData.init.acmeServer); + } +, stagingAcme: function () { + console.log("staging acme:"); + appData.init.acmeServer = STAGING_ACME; + console.log(appData.init.acmeServer); + } +, defaultRelay: function () { + appData.init.relay = DEFAULT_RELAY; + } +, betaRelay: function () { + appData.init.relay = BETA_RELAY; + } +, enable: function () { + api.enable(); + } +, disable: function () { + api.disable(); + } +, ssh: function (port) { + // -1 to disable + // 0 is auto (22) + // 1-65536 + api.ssh(port || 22); + } +, createShare: function (sub, domain, handler) { + if (sub) { + domain = sub + '.' + domain; + } + api.http({ name: domain, handler: handler, indexes: true }); + appData.newHttp = {}; + } +, createHost: function (sub, domain, handler) { + if (sub) { + domain = sub + '.' + domain; + } + api.http({ name: domain, handler: handler, 'x-forwarded-for': name }); + appData.newHttp = {}; + } +, changePortForward: function (domain, port) { + api.http({ name: domain.name, handler: port }); + } +, deletePortForward: function (domain) { + api.http({ name: domain.name, handler: 'none' }); + } +, changePathHost: function (domain, path) { + api.http({ name: domain.name, handler: path }); + } +, deletePathHost: function (domain) { + api.http({ name: domain.name, handler: 'none' }); + } +, changeState: changeState +}; +var appStates = { + setup: function () { + appData.views.section = { setup: true }; + } +, advanced: function () { + appData.views.section = { advanced: true }; + } +, otp: function () { + appData.views.section = { otp: true }; + } +, status: function () { + function exitState() { + clearInterval(tok); + } + + var tok = setInterval(updateStatus, 2000); + + return updateStatus().then(function () { + appData.views.section = { status: true, status_chooser: true }; + return exitState; + }); + } +}; +appStates.status.share = function () { + function exitState() { + clearInterval(tok); + } + + var tok = setInterval(updateStatus, 2000); + + appData.views.section = { status: true, status_share: true }; + return updateStatus().then(function () { + return exitState; + }); +}; +appStates.status.host = function () { + function exitState() { + clearInterval(tok); + } + + var tok = setInterval(updateStatus, 2000); + + appData.views.section = { status: true, status_host: true }; + return updateStatus().then(function () { + return exitState; + }); +}; +appStates.status.access = function () { + function exitState() { + clearInterval(tok); + } + + var tok = setInterval(updateStatus, 2000); + + appData.views.section = { status: true, status_access: true }; + return updateStatus().then(function () { + return exitState; + }); +}; + +function updateStatus() { + return api.status().then(function (status) { + if (status.error) { + appData.views.flash.error = status.error.message || JSON.stringify(status.error, null, 2); + } + var wilddomains = []; + var rootdomains = []; + var subdomains = []; + var directories = []; + var portforwards = []; + var free = []; + appData.status = status; + if ('maybe' === status.ssh_requests_password) { + appData.status.ssh_active = false; + } else { + appData.status.ssh_active = true; + if ('yes' === status.ssh_requests_password) { + appData.status.ssh_insecure = true; + } + } + if ('yes' === status.ssh_password_authentication) { + appData.status.ssh_insecure = true; + } + if ('yes' === status.ssh_permit_root_login) { + appData.status.ssh_insecure = true; + } + + // only update what's changed + if (appData.state.ssh !== appData.status.ssh) { + appData.state.ssh = appData.status.ssh; + } + if (appData.state.ssh_insecure !== appData.status.ssh_insecure) { + appData.state.ssh_insecure = appData.status.ssh_insecure; + } + if (appData.state.ssh_active !== appData.status.ssh_active) { + appData.state.ssh_active = appData.status.ssh_active; + } + Object.keys(appData.status.servernames).forEach(function (k) { + var s = appData.status.servernames[k]; + s.name = k; + if (s.wildcard) { wilddomains.push(s); } + if (!s.sub && !s.wildcard) { rootdomains.push(s); } + if (s.sub) { subdomains.push(s); } + if (s.handler) { + if (s.handler.toString() === parseInt(s.handler, 10).toString()) { + s._port = s.handler; + portforwards.push(s); + } else { + s.path = s.handler; + directories.push(s); + } + } else { + free.push(s); + } + }); + appData.status.portForwards = portforwards; + appData.status.pathHosting = directories; + appData.status.wildDomains = wilddomains; + appData.newHttp.name = (appData.status.wildDomains[0] || {}).name; + appData.state.ssh = (appData.status.ssh > 0) && appData.status.ssh || undefined; + }); +} + +function changeState(newstate) { + var newhash = '#/' + newstate + '/'; + if (location.hash === newhash) { + if (!telebitState.firstState) { + telebitState.firstState = true; + setState(); + } + } + location.hash = newhash; +} +/*globals Promise*/ +window.addEventListener('hashchange', setState, false); +function setState(/*ev*/) { + //ev.oldURL + //ev.newURL + if (appData.exit) { + console.log('previous state exiting'); + appData.exit.then(function (exit) { + if ('function' === typeof exit) { + exit(); + } + }); + } + var parts = location.hash.substr(1).replace(/^\//, '').replace(/\/$/, '').split('/').filter(Boolean); + var fn = appStates; + parts.forEach(function (s) { + console.log("state:", s); + fn = fn[s]; + }); + appData.exit = Promise.resolve(fn()); + //appMethods.states[newstate](); +} + +function msToHumanReadable(ms) { + var uptime = ms; + var uptimed = uptime / 1000; + var minute = 60; + var hour = 60 * minute; + var day = 24 * hour; + var days = 0; + var times = []; + while (uptimed > day) { + uptimed -= day; + days += 1; + } + times.push(days + " days "); + var hours = 0; + while (uptimed > hour) { + uptimed -= hour; + hours += 1; + } + times.push(hours.toString().padStart(2, "0") + " h "); + var minutes = 0; + while (uptimed > minute) { + uptimed -= minute; + minutes += 1; + } + times.push(minutes.toString().padStart(2, "0") + " m "); + var seconds = Math.round(uptimed); + times.push(seconds.toString().padStart(2, "0") + " s "); + return times.join(''); +} + +new Vue({ + el: ".v-app" +, data: appData +, computed: { + statusProctime: function () { + return msToHumanReadable(this.status.proctime); + } + , statusRuntime: function () { + return msToHumanReadable(this.status.runtime); + } + , statusUptime: function () { + return msToHumanReadable(this.status.uptime); + } + } +, methods: appMethods +}); + +function requestAccountHelper() { + function reset() { + changeState('setup'); + setState(); + } + return new Promise(function (resolve) { + appData.init.email = localStorage.getItem('email'); + if (!appData.init.email) { + // don't resolve + reset(); + return; + } + return requestAccount(appData.init.email).then(function (key) { + if (!key) { throw new Error("[SANITY] Error: completed without key"); } + resolve(key); + }).catch(function (err) { + appData.init.email = ""; + localStorage.removeItem('email'); + console.error(err); + window.alert("something went wrong"); + // don't resolve + reset(); + }); + }); +} + +function run() { + return requestAccountHelper().then(function (key) { + api._key = key; + // TODO create session instance of Telebit + Telebit._key = key; + // 😁 1. Get ACME directory + // 😁 2. Fetch ACME account + // 3. Test if account has access + // 4. Show command line auth instructions to auth + // 😁 5. Sign requests / use JWT + // 😁 6. Enforce token required for config, status, etc + // 7. Move admin interface to standard ports (admin.foo-bar-123.telebit.xyz) + api.config().then(function (config) { + telebitState.config = config; + if (config.greenlock) { + appData.init.acmeServer = config.greenlock.server; + } + if (config.relay) { + appData.init.relay = config.relay; + } + if (config.email) { + appData.init.email = config.email; + } + if (config.agreeTos) { + appData.init.letos = config.agreeTos; + appData.init.teletos = config.agreeTos; + } + if (config._otp) { + appData.init.otp = config._otp; + } + + telebitState.pollUrl = config._pollUrl || localStorage.getItem('poll_url'); + + if ((!config.token && !config._otp) || !config.relay || !config.email || !config.agreeTos) { + changeState('setup'); + setState(); + return; + } + if (!config.token && config._otp) { + changeState('otp'); + setState(); + // this will skip ahead as necessary + return Telebit.authorize(telebitState, showOtp).then(function () { + return changeState('status'); + }); + } + + // TODO handle default state + changeState('status'); + }).catch(function (err) { + appData.views.flash.error = err.message || JSON.stringify(err, null, 2); + }); + }); +} + + +// TODO protect key with passphrase (or QR code?) +function getKey() { + var jwk; + try { + jwk = JSON.parse(localStorage.getItem('key')); + } catch(e) { + // ignore + } + if (jwk && jwk.kid && jwk.d) { + return Promise.resolve(jwk); + } + return Keypairs.generate().then(function (pair) { + jwk = pair.private; + localStorage.setItem('key', JSON.stringify(jwk)); + return jwk; + }); +} + +function requestAccount(email) { + return getKey().then(function (jwk) { + // creates new or returns existing + var acme = ACME.create({}); + var url = window.location.protocol + '//' + window.location.host + '/acme/directory'; + return acme.init(url).then(function () { + return acme.accounts.create({ + agreeToTerms: function (tos) { return tos; } + , accountKeypair: { privateKeyJwk: jwk } + , email: email + }).then(function (account) { + console.log('account:'); + console.log(account); + if (account.id) { + localStorage.setItem('email', email); + } + return jwk; + }); + }); + }); +} + +window.api = api; +run(); +setTimeout(function () { + document.body.hidden = false; +}, 50); + +// Debug +window.changeState = changeState; +}()); diff --git a/lib/admin/js/bluecrypt-acme.js b/lib/admin/js/bluecrypt-acme.js new file mode 100644 index 0000000..e4a5996 --- /dev/null +++ b/lib/admin/js/bluecrypt-acme.js @@ -0,0 +1,2828 @@ +// Copyright 2015-2019 AJ ONeal. All rights reserved +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +; +(function (exports) { + +var Enc = exports.Enc = {}; + +Enc.bufToBin = function (buf) { + var bin = ''; + // cannot use .map() because Uint8Array would return only 0s + buf.forEach(function (ch) { + bin += String.fromCharCode(ch); + }); + return bin; +}; + +Enc.bufToHex = function toHex(u8) { + var hex = []; + var i, h; + var len = (u8.byteLength || u8.length); + + for (i = 0; i < len; i += 1) { + h = u8[i].toString(16); + if (h.length % 2) { h = '0' + h; } + hex.push(h); + } + + return hex.join('').toLowerCase(); +}; + +Enc.urlBase64ToBase64 = function urlsafeBase64ToBase64(str) { + var r = str % 4; + if (2 === r) { + str += '=='; + } else if (3 === r) { + str += '='; + } + return str.replace(/-/g, '+').replace(/_/g, '/'); +}; + +Enc.base64ToBuf = function (b64) { + return Enc.binToBuf(atob(b64)); +}; +Enc.binToBuf = function (bin) { + var arr = bin.split('').map(function (ch) { + return ch.charCodeAt(0); + }); + return 'undefined' !== typeof Uint8Array ? new Uint8Array(arr) : arr; +}; +Enc.bufToHex = function (u8) { + var hex = []; + var i, h; + var len = (u8.byteLength || u8.length); + + for (i = 0; i < len; i += 1) { + h = u8[i].toString(16); + if (h.length % 2) { h = '0' + h; } + hex.push(h); + } + + return hex.join('').toLowerCase(); +}; +Enc.numToHex = function (d) { + d = d.toString(16); + if (d.length % 2) { + return '0' + d; + } + return d; +}; + +Enc.bufToUrlBase64 = function (u8) { + return Enc.base64ToUrlBase64(Enc.bufToBase64(u8)); +}; + +Enc.base64ToUrlBase64 = function (str) { + return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; + +Enc.bufToBase64 = function (u8) { + var bin = ''; + u8.forEach(function (i) { + bin += String.fromCharCode(i); + }); + return btoa(bin); +}; + +Enc.hexToBuf = function (hex) { + var arr = []; + hex.match(/.{2}/g).forEach(function (h) { + arr.push(parseInt(h, 16)); + }); + return 'undefined' !== typeof Uint8Array ? new Uint8Array(arr) : arr; +}; + +Enc.numToHex = function (d) { + d = d.toString(16); + if (d.length % 2) { + return '0' + d; + } + return d; +}; + + +// +// JWK to SSH (tested working) +// +Enc.base64ToHex = function (b64) { + var bin = atob(Enc.urlBase64ToBase64(b64)); + return Enc.binToHex(bin); +}; + +Enc.binToHex = function (bin) { + return bin.split('').map(function (ch) { + var h = ch.charCodeAt(0).toString(16); + if (h.length % 2) { h = '0' + h; } + return h; + }).join(''); +}; +// TODO are there any nuance differences here? +Enc.utf8ToHex = Enc.binToHex; + +Enc.hexToBase64 = function (hex) { + return btoa(Enc.hexToBin(hex)); +}; + +Enc.hexToBin = function (hex) { + return hex.match(/.{2}/g).map(function (h) { + return String.fromCharCode(parseInt(h, 16)); + }).join(''); +}; + +Enc.urlBase64ToBase64 = function urlsafeBase64ToBase64(str) { + var r = str % 4; + if (2 === r) { + str += '=='; + } else if (3 === r) { + str += '='; + } + return str.replace(/-/g, '+').replace(/_/g, '/'); +}; + + +}('undefined' !== typeof exports ? module.exports : window )); +;(function (exports) { +'use strict'; + +if (!exports.ASN1) { exports.ASN1 = {}; } +if (!exports.Enc) { exports.Enc = {}; } +if (!exports.PEM) { exports.PEM = {}; } + +var ASN1 = exports.ASN1; +var Enc = exports.Enc; +var PEM = exports.PEM; + +// +// Packer +// + +// Almost every ASN.1 type that's important for CSR +// can be represented generically with only a few rules. +exports.ASN1 = function ASN1(/*type, hexstrings...*/) { + var args = Array.prototype.slice.call(arguments); + var typ = args.shift(); + var str = args.join('').replace(/\s+/g, '').toLowerCase(); + var len = (str.length/2); + var lenlen = 0; + var hex = typ; + + // We can't have an odd number of hex chars + if (len !== Math.round(len)) { + throw new Error("invalid hex"); + } + + // The first byte of any ASN.1 sequence is the type (Sequence, Integer, etc) + // The second byte is either the size of the value, or the size of its size + + // 1. If the second byte is < 0x80 (128) it is considered the size + // 2. If it is > 0x80 then it describes the number of bytes of the size + // ex: 0x82 means the next 2 bytes describe the size of the value + // 3. The special case of exactly 0x80 is "indefinite" length (to end-of-file) + + if (len > 127) { + lenlen += 1; + while (len > 255) { + lenlen += 1; + len = len >> 8; + } + } + + if (lenlen) { hex += Enc.numToHex(0x80 + lenlen); } + return hex + Enc.numToHex(str.length/2) + str; +}; + +// The Integer type has some special rules +ASN1.UInt = function UINT() { + var str = Array.prototype.slice.call(arguments).join(''); + var first = parseInt(str.slice(0, 2), 16); + + // If the first byte is 0x80 or greater, the number is considered negative + // Therefore we add a '00' prefix if the 0x80 bit is set + if (0x80 & first) { str = '00' + str; } + + return ASN1('02', str); +}; + +// The Bit String type also has a special rule +ASN1.BitStr = function BITSTR() { + var str = Array.prototype.slice.call(arguments).join(''); + // '00' is a mask of how many bits of the next byte to ignore + return ASN1('03', '00' + str); +}; + +ASN1.pack = function (arr) { + var typ = Enc.numToHex(arr[0]); + var str = ''; + if (Array.isArray(arr[1])) { + arr[1].forEach(function (a) { + str += ASN1.pack(a); + }); + } else if ('string' === typeof arr[1]) { + str = arr[1]; + } else { + throw new Error("unexpected array"); + } + if ('03' === typ) { + return ASN1.BitStr(str); + } else if ('02' === typ) { + return ASN1.UInt(str); + } else { + return ASN1(typ, str); + } +}; +Object.keys(ASN1).forEach(function (k) { + exports.ASN1[k] = ASN1[k]; +}); +ASN1 = exports.ASN1; + +PEM.packBlock = function (opts) { + // TODO allow for headers? + return '-----BEGIN ' + opts.type + '-----\n' + + Enc.bufToBase64(opts.bytes).match(/.{1,64}/g).join('\n') + '\n' + + '-----END ' + opts.type + '-----' + ; +}; + +Enc.bufToBase64 = function (u8) { + var bin = ''; + u8.forEach(function (i) { + bin += String.fromCharCode(i); + }); + return btoa(bin); +}; + +Enc.hexToBuf = function (hex) { + var arr = []; + hex.match(/.{2}/g).forEach(function (h) { + arr.push(parseInt(h, 16)); + }); + return 'undefined' !== typeof Uint8Array ? new Uint8Array(arr) : arr; +}; + +Enc.numToHex = function (d) { + d = d.toString(16); + if (d.length % 2) { + return '0' + d; + } + return d; +}; + +}('undefined' !== typeof window ? window : module.exports)); +(function (exports) { + 'use strict'; + + var x509 = exports.x509 = {}; + var ASN1 = exports.ASN1; + var Enc = exports.Enc; + + // 1.2.840.10045.3.1.7 + // prime256v1 (ANSI X9.62 named elliptic curve) + var OBJ_ID_EC = '06 08 2A8648CE3D030107'.replace(/\s+/g, '').toLowerCase(); + // 1.3.132.0.34 + // secp384r1 (SECG (Certicom) named elliptic curve) + var OBJ_ID_EC_384 = '06 05 2B81040022'.replace(/\s+/g, '').toLowerCase(); + // 1.2.840.10045.2.1 + // ecPublicKey (ANSI X9.62 public key type) + var OBJ_ID_EC_PUB = '06 07 2A8648CE3D0201'.replace(/\s+/g, '').toLowerCase(); + + x509.parseSec1 = function parseEcOnlyPrivkey(u8, jwk) { + var index = 7; + var len = 32; + var olen = OBJ_ID_EC.length / 2; + + if ("P-384" === jwk.crv) { + olen = OBJ_ID_EC_384.length / 2; + index = 8; + len = 48; + } + if (len !== u8[index - 1]) { + throw new Error("Unexpected bitlength " + len); + } + + // private part is d + var d = u8.slice(index, index + len); + // compression bit index + var ci = index + len + 2 + olen + 2 + 3; + var c = u8[ci]; + var x, y; + + if (0x04 === c) { + y = u8.slice(ci + 1 + len, ci + 1 + len + len); + } else if (0x02 !== c) { + throw new Error("not a supported EC private key"); + } + x = u8.slice(ci + 1, ci + 1 + len); + + return { + kty: jwk.kty + , crv: jwk.crv + , d: Enc.bufToUrlBase64(d) + //, dh: Enc.bufToHex(d) + , x: Enc.bufToUrlBase64(x) + //, xh: Enc.bufToHex(x) + , y: Enc.bufToUrlBase64(y) + //, yh: Enc.bufToHex(y) + }; + }; + + x509.packPkcs1 = function (jwk) { + var n = ASN1.UInt(Enc.base64ToHex(jwk.n)); + var e = ASN1.UInt(Enc.base64ToHex(jwk.e)); + + if (!jwk.d) { + return Enc.hexToBuf(ASN1('30', n, e)); + } + + return Enc.hexToBuf(ASN1('30' + , ASN1.UInt('00') + , n + , e + , ASN1.UInt(Enc.base64ToHex(jwk.d)) + , ASN1.UInt(Enc.base64ToHex(jwk.p)) + , ASN1.UInt(Enc.base64ToHex(jwk.q)) + , ASN1.UInt(Enc.base64ToHex(jwk.dp)) + , ASN1.UInt(Enc.base64ToHex(jwk.dq)) + , ASN1.UInt(Enc.base64ToHex(jwk.qi)) + )); + }; + + x509.parsePkcs8 = function parseEcPkcs8(u8, jwk) { + var index = 24 + (OBJ_ID_EC.length / 2); + var len = 32; + if ("P-384" === jwk.crv) { + index = 24 + (OBJ_ID_EC_384.length / 2) + 2; + len = 48; + } + + //console.log(index, u8.slice(index)); + if (0x04 !== u8[index]) { + //console.log(jwk); + throw new Error("privkey not found"); + } + var d = u8.slice(index + 2, index + 2 + len); + var ci = index + 2 + len + 5; + var xi = ci + 1; + var x = u8.slice(xi, xi + len); + var yi = xi + len; + var y; + if (0x04 === u8[ci]) { + y = u8.slice(yi, yi + len); + } else if (0x02 !== u8[ci]) { + throw new Error("invalid compression bit (expected 0x04 or 0x02)"); + } + + return { + kty: jwk.kty + , crv: jwk.crv + , d: Enc.bufToUrlBase64(d) + //, dh: Enc.bufToHex(d) + , x: Enc.bufToUrlBase64(x) + //, xh: Enc.bufToHex(x) + , y: Enc.bufToUrlBase64(y) + //, yh: Enc.bufToHex(y) + }; + }; + + x509.parseSpki = function parsePem(u8, jwk) { + var ci = 16 + OBJ_ID_EC.length / 2; + var len = 32; + + if ("P-384" === jwk.crv) { + ci = 16 + OBJ_ID_EC_384.length / 2; + len = 48; + } + + var c = u8[ci]; + var xi = ci + 1; + var x = u8.slice(xi, xi + len); + var yi = xi + len; + var y; + if (0x04 === c) { + y = u8.slice(yi, yi + len); + } else if (0x02 !== c) { + throw new Error("not a supported EC private key"); + } + + return { + kty: jwk.kty + , crv: jwk.crv + , x: Enc.bufToUrlBase64(x) + //, xh: Enc.bufToHex(x) + , y: Enc.bufToUrlBase64(y) + //, yh: Enc.bufToHex(y) + }; + }; + x509.parsePkix = x509.parseSpki; + + x509.packSec1 = function (jwk) { + var d = Enc.base64ToHex(jwk.d); + var x = Enc.base64ToHex(jwk.x); + var y = Enc.base64ToHex(jwk.y); + var objId = ('P-256' === jwk.crv) ? OBJ_ID_EC : OBJ_ID_EC_384; + return Enc.hexToBuf( + ASN1('30' + , ASN1.UInt('01') + , ASN1('04', d) + , ASN1('A0', objId) + , ASN1('A1', ASN1.BitStr('04' + x + y))) + ); + }; + /** + * take a private jwk and creates a der from it + * @param {*} jwk + */ + x509.packPkcs8 = function (jwk) { + if ('RSA' === jwk.kty) { + if (!jwk.d) { + // Public RSA + return Enc.hexToBuf(ASN1('30' + , ASN1('30' + , ASN1('06', '2a864886f70d010101') + , ASN1('05') + ) + , ASN1.BitStr(ASN1('30' + , ASN1.UInt(Enc.base64ToHex(jwk.n)) + , ASN1.UInt(Enc.base64ToHex(jwk.e)) + )) + )); + } + + // Private RSA + return Enc.hexToBuf(ASN1('30' + , ASN1.UInt('00') + , ASN1('30' + , ASN1('06', '2a864886f70d010101') + , ASN1('05') + ) + , ASN1('04' + , ASN1('30' + , ASN1.UInt('00') + , ASN1.UInt(Enc.base64ToHex(jwk.n)) + , ASN1.UInt(Enc.base64ToHex(jwk.e)) + , ASN1.UInt(Enc.base64ToHex(jwk.d)) + , ASN1.UInt(Enc.base64ToHex(jwk.p)) + , ASN1.UInt(Enc.base64ToHex(jwk.q)) + , ASN1.UInt(Enc.base64ToHex(jwk.dp)) + , ASN1.UInt(Enc.base64ToHex(jwk.dq)) + , ASN1.UInt(Enc.base64ToHex(jwk.qi)) + ) + ) + )); + } + + var d = Enc.base64ToHex(jwk.d); + var x = Enc.base64ToHex(jwk.x); + var y = Enc.base64ToHex(jwk.y); + var objId = ('P-256' === jwk.crv) ? OBJ_ID_EC : OBJ_ID_EC_384; + return Enc.hexToBuf( + ASN1('30' + , ASN1.UInt('00') + , ASN1('30' + , OBJ_ID_EC_PUB + , objId + ) + , ASN1('04' + , ASN1('30' + , ASN1.UInt('01') + , ASN1('04', d) + , ASN1('A1', ASN1.BitStr('04' + x + y))))) + ); + }; + x509.packSpki = function (jwk) { + if (/EC/i.test(jwk.kty)) { + return x509.packSpkiEc(jwk); + } + return x509.packSpkiRsa(jwk); + }; + x509.packSpkiRsa = function (jwk) { + if (!jwk.d) { + // Public RSA + return Enc.hexToBuf(ASN1('30' + , ASN1('30' + , ASN1('06', '2a864886f70d010101') + , ASN1('05') + ) + , ASN1.BitStr(ASN1('30' + , ASN1.UInt(Enc.base64ToHex(jwk.n)) + , ASN1.UInt(Enc.base64ToHex(jwk.e)) + )) + )); + } + + // Private RSA + return Enc.hexToBuf(ASN1('30' + , ASN1.UInt('00') + , ASN1('30' + , ASN1('06', '2a864886f70d010101') + , ASN1('05') + ) + , ASN1('04' + , ASN1('30' + , ASN1.UInt('00') + , ASN1.UInt(Enc.base64ToHex(jwk.n)) + , ASN1.UInt(Enc.base64ToHex(jwk.e)) + , ASN1.UInt(Enc.base64ToHex(jwk.d)) + , ASN1.UInt(Enc.base64ToHex(jwk.p)) + , ASN1.UInt(Enc.base64ToHex(jwk.q)) + , ASN1.UInt(Enc.base64ToHex(jwk.dp)) + , ASN1.UInt(Enc.base64ToHex(jwk.dq)) + , ASN1.UInt(Enc.base64ToHex(jwk.qi)) + ) + ) + )); +}; + x509.packSpkiEc = function (jwk) { + var x = Enc.base64ToHex(jwk.x); + var y = Enc.base64ToHex(jwk.y); + var objId = ('P-256' === jwk.crv) ? OBJ_ID_EC : OBJ_ID_EC_384; + return Enc.hexToBuf( + ASN1('30' + , ASN1('30' + , OBJ_ID_EC_PUB + , objId + ) + , ASN1.BitStr('04' + x + y)) + ); + }; + x509.packPkix = x509.packSpki; + +}('undefined' !== typeof module ? module.exports : window)); +/*global Promise*/ +(function (exports) { +'use strict'; + +var EC = exports.Eckles = {}; +var x509 = exports.x509; +if ('undefined' !== typeof module) { module.exports = EC; } +var PEM = exports.PEM; +var SSH = exports.SSH; +var Enc = {}; +var textEncoder = new TextEncoder(); + +EC._stance = "We take the stance that if you're knowledgeable enough to" + + " properly and securely use non-standard crypto then you shouldn't need Bluecrypt anyway."; +EC._universal = "Bluecrypt only supports crypto with standard cross-browser and cross-platform support."; +EC.generate = function (opts) { + var wcOpts = {}; + if (!opts) { opts = {}; } + if (!opts.kty) { opts.kty = 'EC'; } + + // ECDSA has only the P curves and an associated bitlength + wcOpts.name = 'ECDSA'; + if (!opts.namedCurve) { + opts.namedCurve = 'P-256'; + } + wcOpts.namedCurve = opts.namedCurve; // true for supported curves + if (/256/.test(wcOpts.namedCurve)) { + wcOpts.namedCurve = 'P-256'; + wcOpts.hash = { name: "SHA-256" }; + } else if (/384/.test(wcOpts.namedCurve)) { + wcOpts.namedCurve = 'P-384'; + wcOpts.hash = { name: "SHA-384" }; + } else { + return Promise.Reject(new Error("'" + wcOpts.namedCurve + "' is not an NIST approved ECDSA namedCurve. " + + " Please choose either 'P-256' or 'P-384'. " + + EC._stance)); + } + + var extractable = true; + return window.crypto.subtle.generateKey( + wcOpts + , extractable + , [ 'sign', 'verify' ] + ).then(function (result) { + return window.crypto.subtle.exportKey( + "jwk" + , result.privateKey + ).then(function (privJwk) { + privJwk.key_ops = undefined; + privJwk.ext = undefined; + return { + private: privJwk + , public: EC.neuter({ jwk: privJwk }) + }; + }); + }); +}; + +EC.export = function (opts) { + return Promise.resolve().then(function () { + if (!opts || !opts.jwk || 'object' !== typeof opts.jwk) { + throw new Error("must pass { jwk: jwk } as a JSON object"); + } + var jwk = JSON.parse(JSON.stringify(opts.jwk)); + var format = opts.format; + if (opts.public || -1 !== [ 'spki', 'pkix', 'ssh', 'rfc4716' ].indexOf(format)) { + jwk.d = null; + } + if ('EC' !== jwk.kty) { + throw new Error("options.jwk.kty must be 'EC' for EC keys"); + } + if (!jwk.d) { + if (!format || -1 !== [ 'spki', 'pkix' ].indexOf(format)) { + format = 'spki'; + } else if (-1 !== [ 'ssh', 'rfc4716' ].indexOf(format)) { + format = 'ssh'; + } else { + throw new Error("options.format must be 'spki' or 'ssh' for public EC keys, not (" + + typeof format + ") " + format); + } + } else { + if (!format || 'sec1' === format) { + format = 'sec1'; + } else if ('pkcs8' !== format) { + throw new Error("options.format must be 'sec1' or 'pkcs8' for private EC keys, not '" + format + "'"); + } + } + if (-1 === [ 'P-256', 'P-384' ].indexOf(jwk.crv)) { + throw new Error("options.jwk.crv must be either P-256 or P-384 for EC keys, not '" + jwk.crv + "'"); + } + if (!jwk.y) { + throw new Error("options.jwk.y must be a urlsafe base64-encoded either P-256 or P-384"); + } + + if ('sec1' === format) { + return PEM.packBlock({ type: "EC PRIVATE KEY", bytes: x509.packSec1(jwk) }); + } else if ('pkcs8' === format) { + return PEM.packBlock({ type: "PRIVATE KEY", bytes: x509.packPkcs8(jwk) }); + } else if (-1 !== [ 'spki', 'pkix' ].indexOf(format)) { + return PEM.packBlock({ type: "PUBLIC KEY", bytes: x509.packSpki(jwk) }); + } else if (-1 !== [ 'ssh', 'rfc4716' ].indexOf(format)) { + return SSH.packSsh(jwk); + } else { + throw new Error("Sanity Error: reached unreachable code block with format: " + format); + } + }); +}; +EC.pack = function (opts) { + return Promise.resolve().then(function () { + return EC.exportSync(opts); + }); +}; + +// Chopping off the private parts is now part of the public API. +// I thought it sounded a little too crude at first, but it really is the best name in every possible way. +EC.neuter = function (opts) { + // trying to find the best balance of an immutable copy with custom attributes + var jwk = {}; + Object.keys(opts.jwk).forEach(function (k) { + if ('undefined' === typeof opts.jwk[k]) { return; } + // ignore EC private parts + if ('d' === k) { return; } + jwk[k] = JSON.parse(JSON.stringify(opts.jwk[k])); + }); + return jwk; +}; + +// https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk +EC.__thumbprint = function (jwk) { + // Use the same entropy for SHA as for key + var alg = 'SHA-256'; + if (/384/.test(jwk.crv)) { + alg = 'SHA-384'; + } + return window.crypto.subtle.digest( + { name: alg } + , textEncoder.encode('{"crv":"' + jwk.crv + '","kty":"EC","x":"' + jwk.x + '","y":"' + jwk.y + '"}') + ).then(function (hash) { + return Enc.bufToUrlBase64(new Uint8Array(hash)); + }); +}; + +EC.thumbprint = function (opts) { + return Promise.resolve().then(function () { + var jwk; + if ('EC' === opts.kty) { + jwk = opts; + } else if (opts.jwk) { + jwk = opts.jwk; + } else { + return EC.import(opts).then(function (jwk) { + return EC.__thumbprint(jwk); + }); + } + return EC.__thumbprint(jwk); + }); +}; + +Enc.bufToUrlBase64 = function (u8) { + return Enc.bufToBase64(u8) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; + +Enc.bufToBase64 = function (u8) { + var bin = ''; + u8.forEach(function (i) { + bin += String.fromCharCode(i); + }); + return btoa(bin); +}; + +}('undefined' !== typeof module ? module.exports : window)); +/*global Promise*/ +(function (exports) { +'use strict'; + +var RSA = exports.Rasha = {}; +var x509 = exports.x509; +if ('undefined' !== typeof module) { module.exports = RSA; } +var PEM = exports.PEM; +var SSH = exports.SSH; +var Enc = {}; +var textEncoder = new TextEncoder(); + +RSA._stance = "We take the stance that if you're knowledgeable enough to" + + " properly and securely use non-standard crypto then you shouldn't need Bluecrypt anyway."; +RSA._universal = "Bluecrypt only supports crypto with standard cross-browser and cross-platform support."; +RSA.generate = function (opts) { + var wcOpts = {}; + if (!opts) { opts = {}; } + if (!opts.kty) { opts.kty = 'RSA'; } + + // Support PSS? I don't think it's used for Let's Encrypt + wcOpts.name = 'RSASSA-PKCS1-v1_5'; + if (!opts.modulusLength) { + opts.modulusLength = 2048; + } + wcOpts.modulusLength = opts.modulusLength; + if (wcOpts.modulusLength >= 2048 && wcOpts.modulusLength < 3072) { + // erring on the small side... for no good reason + wcOpts.hash = { name: "SHA-256" }; + } else if (wcOpts.modulusLength >= 3072 && wcOpts.modulusLength < 4096) { + wcOpts.hash = { name: "SHA-384" }; + } else if (wcOpts.modulusLength < 4097) { + wcOpts.hash = { name: "SHA-512" }; + } else { + // Public key thumbprints should be paired with a hash of similar length, + // so anything above SHA-512's keyspace would be left under-represented anyway. + return Promise.Reject(new Error("'" + wcOpts.modulusLength + "' is not within the safe and universally" + + " acceptable range of 2048-4096. Typically you should pick 2048, 3072, or 4096, though other values" + + " divisible by 8 are allowed. " + RSA._stance)); + } + // TODO maybe allow this to be set to any of the standard values? + wcOpts.publicExponent = new Uint8Array([0x01, 0x00, 0x01]); + + var extractable = true; + return window.crypto.subtle.generateKey( + wcOpts + , extractable + , [ 'sign', 'verify' ] + ).then(function (result) { + return window.crypto.subtle.exportKey( + "jwk" + , result.privateKey + ).then(function (privJwk) { + return { + private: privJwk + , public: RSA.neuter({ jwk: privJwk }) + }; + }); + }); +}; + +// Chopping off the private parts is now part of the public API. +// I thought it sounded a little too crude at first, but it really is the best name in every possible way. +RSA.neuter = function (opts) { + // trying to find the best balance of an immutable copy with custom attributes + var jwk = {}; + Object.keys(opts.jwk).forEach(function (k) { + if ('undefined' === typeof opts.jwk[k]) { return; } + // ignore RSA private parts + if (-1 !== ['d', 'p', 'q', 'dp', 'dq', 'qi'].indexOf(k)) { return; } + jwk[k] = JSON.parse(JSON.stringify(opts.jwk[k])); + }); + return jwk; +}; + +// https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk +RSA.__thumbprint = function (jwk) { + // Use the same entropy for SHA as for key + var len = Math.floor(jwk.n.length * 0.75); + var alg = 'SHA-256'; + // TODO this may be a bug + // need to confirm that the padding is no more or less than 1 byte + if (len >= 511) { + alg = 'SHA-512'; + } else if (len >= 383) { + alg = 'SHA-384'; + } + return window.crypto.subtle.digest( + { name: alg } + , textEncoder.encode('{"e":"' + jwk.e + '","kty":"RSA","n":"' + jwk.n + '"}') + ).then(function (hash) { + return Enc.bufToUrlBase64(new Uint8Array(hash)); + }); +}; + +RSA.thumbprint = function (opts) { + return Promise.resolve().then(function () { + var jwk; + if ('EC' === opts.kty) { + jwk = opts; + } else if (opts.jwk) { + jwk = opts.jwk; + } else { + return RSA.import(opts).then(function (jwk) { + return RSA.__thumbprint(jwk); + }); + } + return RSA.__thumbprint(jwk); + }); +}; + +RSA.export = function (opts) { + return Promise.resolve().then(function () { + if (!opts || !opts.jwk || 'object' !== typeof opts.jwk) { + throw new Error("must pass { jwk: jwk }"); + } + var jwk = JSON.parse(JSON.stringify(opts.jwk)); + var format = opts.format; + var pub = opts.public; + if (pub || -1 !== [ 'spki', 'pkix', 'ssh', 'rfc4716' ].indexOf(format)) { + jwk = RSA.neuter({ jwk: jwk }); + } + if ('RSA' !== jwk.kty) { + throw new Error("options.jwk.kty must be 'RSA' for RSA keys"); + } + if (!jwk.p) { + // TODO test for n and e + pub = true; + if (!format || 'pkcs1' === format) { + format = 'pkcs1'; + } else if (-1 !== [ 'spki', 'pkix' ].indexOf(format)) { + format = 'spki'; + } else if (-1 !== [ 'ssh', 'rfc4716' ].indexOf(format)) { + format = 'ssh'; + } else { + throw new Error("options.format must be 'spki', 'pkcs1', or 'ssh' for public RSA keys, not (" + + typeof format + ") " + format); + } + } else { + // TODO test for all necessary keys (d, p, q ...) + if (!format || 'pkcs1' === format) { + format = 'pkcs1'; + } else if ('pkcs8' !== format) { + throw new Error("options.format must be 'pkcs1' or 'pkcs8' for private RSA keys"); + } + } + + if ('pkcs1' === format) { + if (jwk.d) { + return PEM.packBlock({ type: "RSA PRIVATE KEY", bytes: x509.packPkcs1(jwk) }); + } else { + return PEM.packBlock({ type: "RSA PUBLIC KEY", bytes: x509.packPkcs1(jwk) }); + } + } else if ('pkcs8' === format) { + return PEM.packBlock({ type: "PRIVATE KEY", bytes: x509.packPkcs8(jwk) }); + } else if (-1 !== [ 'spki', 'pkix' ].indexOf(format)) { + return PEM.packBlock({ type: "PUBLIC KEY", bytes: x509.packSpki(jwk) }); + } else if (-1 !== [ 'ssh', 'rfc4716' ].indexOf(format)) { + return SSH.pack({ jwk: jwk, comment: opts.comment }); + } else { + throw new Error("Sanity Error: reached unreachable code block with format: " + format); + } + }); +}; +RSA.pack = function (opts) { + // wrapped in a promise for API compatibility + // with the forthcoming browser version + // (and potential future native node capability) + return Promise.resolve().then(function () { + return RSA.export(opts); + }); +}; + +Enc.bufToUrlBase64 = function (u8) { + return Enc.bufToBase64(u8) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; + +Enc.bufToBase64 = function (u8) { + var bin = ''; + u8.forEach(function (i) { + bin += String.fromCharCode(i); + }); + return btoa(bin); +}; + +}('undefined' !== typeof module ? module.exports : window)); +/*global Promise*/ +(function (exports) { +'use strict'; + +var Keypairs = exports.Keypairs = {}; +var Rasha = exports.Rasha; +var Eckles = exports.Eckles; +var Enc = exports.Enc || {}; + +Keypairs._stance = "We take the stance that if you're knowledgeable enough to" + + " properly and securely use non-standard crypto then you shouldn't need Bluecrypt anyway."; +Keypairs._universal = "Bluecrypt only supports crypto with standard cross-browser and cross-platform support."; +Keypairs.generate = function (opts) { + opts = opts || {}; + var p; + if (!opts.kty) { opts.kty = opts.type; } + if (!opts.kty) { opts.kty = 'EC'; } + if (/^EC/i.test(opts.kty)) { + p = Eckles.generate(opts); + } else if (/^RSA$/i.test(opts.kty)) { + p = Rasha.generate(opts); + } else { + return Promise.Reject(new Error("'" + opts.kty + "' is not a well-supported key type." + + Keypairs._universal + + " Please choose 'EC', or 'RSA' if you have good reason to.")); + } + return p.then(function (pair) { + return Keypairs.thumbprint({ jwk: pair.public }).then(function (thumb) { + pair.private.kid = thumb; // maybe not the same id on the private key? + pair.public.kid = thumb; + return pair; + }); + }); +}; + +Keypairs.export = function (opts) { + return Eckles.export(opts).catch(function (err) { + return Rasha.export(opts).catch(function () { + return Promise.reject(err); + }); + }); +}; + + +/** + * Chopping off the private parts is now part of the public API. + * I thought it sounded a little too crude at first, but it really is the best name in every possible way. + */ +Keypairs.neuter = function (opts) { + /** trying to find the best balance of an immutable copy with custom attributes */ + var jwk = {}; + Object.keys(opts.jwk).forEach(function (k) { + if ('undefined' === typeof opts.jwk[k]) { return; } + // ignore RSA and EC private parts + if (-1 !== ['d', 'p', 'q', 'dp', 'dq', 'qi'].indexOf(k)) { return; } + jwk[k] = JSON.parse(JSON.stringify(opts.jwk[k])); + }); + return jwk; +}; + +Keypairs.thumbprint = function (opts) { + return Promise.resolve().then(function () { + if (/EC/i.test(opts.jwk.kty)) { + return Eckles.thumbprint(opts); + } else { + return Rasha.thumbprint(opts); + } + }); +}; + +Keypairs.publish = function (opts) { + if ('object' !== typeof opts.jwk || !opts.jwk.kty) { throw new Error("invalid jwk: " + JSON.stringify(opts.jwk)); } + + /** returns a copy */ + var jwk = Keypairs.neuter(opts); + + if (jwk.exp) { + jwk.exp = setTime(jwk.exp); + } else { + if (opts.exp) { jwk.exp = setTime(opts.exp); } + else if (opts.expiresIn) { jwk.exp = Math.round(Date.now()/1000) + opts.expiresIn; } + else if (opts.expiresAt) { jwk.exp = opts.expiresAt; } + } + if (!jwk.use && false !== jwk.use) { jwk.use = "sig"; } + + if (jwk.kid) { return Promise.resolve(jwk); } + return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) { jwk.kid = thumb; return jwk; }); +}; + +// JWT a.k.a. JWS with Claims using Compact Serialization +Keypairs.signJwt = function (opts) { + return Keypairs.thumbprint({ jwk: opts.jwk }).then(function (thumb) { + var header = opts.header || {}; + var claims = JSON.parse(JSON.stringify(opts.claims || {})); + header.typ = 'JWT'; + + if (!header.kid) { header.kid = thumb; } + if (!header.alg && opts.alg) { header.alg = opts.alg; } + if (!claims.iat && (false === claims.iat || false === opts.iat)) { + claims.iat = undefined; + } else if (!claims.iat) { + claims.iat = Math.round(Date.now()/1000); + } + + if (opts.exp) { + claims.exp = setTime(opts.exp); + } else if (!claims.exp && (false === claims.exp || false === opts.exp)) { + claims.exp = undefined; + } else if (!claims.exp) { + throw new Error("opts.claims.exp should be the expiration date as seconds, human form (i.e. '1h' or '15m') or false"); + } + + if (opts.iss) { claims.iss = opts.iss; } + if (!claims.iss && (false === claims.iss || false === opts.iss)) { + claims.iss = undefined; + } else if (!claims.iss) { + throw new Error("opts.claims.iss should be in the form of https://example.com/, a secure OIDC base url"); + } + + return Keypairs.signJws({ + jwk: opts.jwk + , pem: opts.pem + , protected: header + , header: undefined + , payload: claims + }).then(function (jws) { + return [ jws.protected, jws.payload, jws.signature ].join('.'); + }); + }); +}; + +Keypairs.signJws = function (opts) { + return Keypairs.thumbprint(opts).then(function (thumb) { + + function alg() { + if (!opts.jwk) { + throw new Error("opts.jwk must exist and must declare 'typ'"); + } + if (opts.jwk.alg) { return opts.jwk.alg; } + var typ = ('RSA' === opts.jwk.kty) ? "RS" : "ES"; + return typ + Keypairs._getBits(opts); + } + + function sign() { + var protect = opts.protected; + var payload = opts.payload; + + // Compute JWS signature + var protectedHeader = ""; + // Because unprotected headers are allowed, regrettably... + // https://stackoverflow.com/a/46288694 + if (false !== protect) { + if (!protect) { protect = {}; } + if (!protect.alg) { protect.alg = alg(); } + // There's a particular request where ACME / Let's Encrypt explicitly doesn't use a kid + if (false === protect.kid) { protect.kid = undefined; } + else if (!protect.kid) { protect.kid = thumb; } + protectedHeader = JSON.stringify(protect); + } + + // Not sure how to handle the empty case since ACME POST-as-GET must be empty + //if (!payload) { + // throw new Error("opts.payload should be JSON, string, or ArrayBuffer (it may be empty, but that must be explicit)"); + //} + // Trying to detect if it's a plain object (not Buffer, ArrayBuffer, Array, Uint8Array, etc) + if (payload && ('string' !== typeof payload) + && ('undefined' === typeof payload.byteLength) + && ('undefined' === typeof payload.buffer) + ) { + payload = JSON.stringify(payload); + } + // Converting to a buffer, even if it was just converted to a string + if ('string' === typeof payload) { + payload = Enc.binToBuf(payload); + } + + // node specifies RSA-SHAxxx even when it's actually ecdsa (it's all encoded x509 shasums anyway) + var protected64 = Enc.strToUrlBase64(protectedHeader); + var payload64 = Enc.bufToUrlBase64(payload); + var msg = protected64 + '.' + payload64; + + return Keypairs._sign(opts, msg).then(function (buf) { + var signedMsg = { + protected: protected64 + , payload: payload64 + , signature: Enc.bufToUrlBase64(buf) + }; + + return signedMsg; + }); + } + + if (opts.jwk) { + return sign(); + } else { + return Keypairs.import({ pem: opts.pem }).then(function (pair) { + opts.jwk = pair.private; + return sign(); + }); + } + }); +}; + +Keypairs._sign = function (opts, payload) { + return Keypairs._import(opts).then(function (privkey) { + if ('string' === typeof payload) { + payload = (new TextEncoder()).encode(payload); + } + return window.crypto.subtle.sign( + { name: Keypairs._getName(opts) + , hash: { name: 'SHA-' + Keypairs._getBits(opts) } + } + , privkey + , payload + ).then(function (signature) { + signature = new Uint8Array(signature); // ArrayBuffer -> u8 + // This will come back into play for CSRs, but not for JOSE + if ('EC' === opts.jwk.kty && /x509|asn1/i.test(opts.format)) { + return Keypairs._ecdsaJoseSigToAsn1Sig(signature); + } else { + // jose/jws/jwt + return signature; + } + }); + }); +}; +Keypairs._getBits = function (opts) { + if (opts.alg) { return opts.alg.replace(/[a-z\-]/ig, ''); } + // base64 len to byte len + var len = Math.floor((opts.jwk.n||'').length * 0.75); + + // TODO this may be a bug + // need to confirm that the padding is no more or less than 1 byte + if (/521/.test(opts.jwk.crv) || len >= 511) { + return '512'; + } else if (/384/.test(opts.jwk.crv) || len >= 383) { + return '384'; + } + + return '256'; +}; +Keypairs._getName = function (opts) { + if (/EC/i.test(opts.jwk.kty)) { + return 'ECDSA'; + } else { + return 'RSASSA-PKCS1-v1_5'; + } +}; +Keypairs._import = function (opts) { + return Promise.resolve().then(function () { + var ops; + // all private keys just happen to have a 'd' + if (opts.jwk.d) { + ops = [ 'sign' ]; + } else { + ops = [ 'verify' ]; + } + // gotta mark it as extractable, as if it matters + opts.jwk.ext = true; + opts.jwk.key_ops = ops; + + return window.crypto.subtle.importKey( + "jwk" + , opts.jwk + , { name: Keypairs._getName(opts) + , namedCurve: opts.jwk.crv + , hash: { name: 'SHA-' + Keypairs._getBits(opts) } } + , true + , ops + ).then(function (privkey) { + delete opts.jwk.ext; + return privkey; + }); + }); +}; +// ECDSA JOSE / JWS / JWT signatures differ from "normal" ASN1/X509 ECDSA signatures +// https://tools.ietf.org/html/rfc7518#section-3.4 +Keypairs._ecdsaJoseSigToAsn1Sig = function (bufsig) { + // it's easier to do the manipulation in the browser with an array + bufsig = Array.from(bufsig); + var hlen = bufsig.length / 2; // should be even + var r = bufsig.slice(0, hlen); + var s = bufsig.slice(hlen); + // unpad positive ints less than 32 bytes wide + while (!r[0]) { r = r.slice(1); } + while (!s[0]) { s = s.slice(1); } + // pad (or re-pad) ambiguously non-negative BigInts, up to 33 bytes wide + if (0x80 & r[0]) { r.unshift(0); } + if (0x80 & s[0]) { s.unshift(0); } + + var len = 2 + r.length + 2 + s.length; + var head = [0x30]; + // hard code 0x80 + 1 because it won't be longer than + // two SHA512 plus two pad bytes (130 bytes <= 256) + if (len >= 0x80) { head.push(0x81); } + head.push(len); + + return Uint8Array.from(head.concat([0x02, r.length], r, [0x02, s.length], s)); +}; + +function setTime(time) { + if ('number' === typeof time) { return time; } + + var t = time.match(/^(\-?\d+)([dhms])$/i); + if (!t || !t[0]) { + throw new Error("'" + time + "' should be datetime in seconds or human-readable format (i.e. 3d, 1h, 15m, 30s"); + } + + var now = Math.round(Date.now()/1000); + var num = parseInt(t[1], 10); + var unit = t[2]; + var mult = 1; + switch(unit) { + // fancy fallthrough, what fun! + case 'd': + mult *= 24; + /*falls through*/ + case 'h': + mult *= 60; + /*falls through*/ + case 'm': + mult *= 60; + /*falls through*/ + case 's': + mult *= 1; + } + + return now + (mult * num); +} + +Enc.hexToBuf = function (hex) { + var arr = []; + hex.match(/.{2}/g).forEach(function (h) { + arr.push(parseInt(h, 16)); + }); + return 'undefined' !== typeof Uint8Array ? new Uint8Array(arr) : arr; +}; +Enc.strToUrlBase64 = function (str) { + return Enc.bufToUrlBase64(Enc.binToBuf(str)); +}; +Enc.binToBuf = function (bin) { + var arr = bin.split('').map(function (ch) { + return ch.charCodeAt(0); + }); + return 'undefined' !== typeof Uint8Array ? new Uint8Array(arr) : arr; +}; + +}('undefined' !== typeof module ? module.exports : window)); +// Copyright 2018 AJ ONeal. All rights reserved +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +;(function (exports) { +'use strict'; + +if (!exports.ASN1) { exports.ASN1 = {}; } +if (!exports.Enc) { exports.Enc = {}; } +if (!exports.PEM) { exports.PEM = {}; } + +var ASN1 = exports.ASN1; +var Enc = exports.Enc; +var PEM = exports.PEM; + +// +// Parser +// + +// Although I've only seen 9 max in https certificates themselves, +// but each domain list could have up to 100 +ASN1.ELOOPN = 102; +ASN1.ELOOP = "uASN1.js Error: iterated over " + ASN1.ELOOPN + "+ elements (probably a malformed file)"; +// I've seen https certificates go 29 deep +ASN1.EDEEPN = 60; +ASN1.EDEEP = "uASN1.js Error: element nested " + ASN1.EDEEPN + "+ layers deep (probably a malformed file)"; +// Container Types are Sequence 0x30, Container Array? (0xA0, 0xA1) +// Value Types are Boolean 0x01, Integer 0x02, Null 0x05, Object ID 0x06, String 0x0C, 0x16, 0x13, 0x1e Value Array? (0x82) +// Bit String (0x03) and Octet String (0x04) may be values or containers +// Sometimes Bit String is used as a container (RSA Pub Spki) +ASN1.CTYPES = [ 0x30, 0x31, 0xa0, 0xa1 ]; +ASN1.VTYPES = [ 0x01, 0x02, 0x05, 0x06, 0x0c, 0x82 ]; +ASN1.parse = function parseAsn1Helper(buf) { + //var ws = ' '; + function parseAsn1(buf, depth, eager) { + if (depth.length >= ASN1.EDEEPN) { throw new Error(ASN1.EDEEP); } + + var index = 2; // we know, at minimum, data starts after type (0) and lengthSize (1) + var asn1 = { type: buf[0], lengthSize: 0, length: buf[1] }; + var child; + var iters = 0; + var adjust = 0; + var adjustedLen; + + // Determine how many bytes the length uses, and what it is + if (0x80 & asn1.length) { + asn1.lengthSize = 0x7f & asn1.length; + // I think that buf->hex->int solves the problem of Endianness... not sure + asn1.length = parseInt(Enc.bufToHex(buf.slice(index, index + asn1.lengthSize)), 16); + index += asn1.lengthSize; + } + + // High-order bit Integers have a leading 0x00 to signify that they are positive. + // Bit Streams use the first byte to signify padding, which x.509 doesn't use. + if (0x00 === buf[index] && (0x02 === asn1.type || 0x03 === asn1.type)) { + // However, 0x00 on its own is a valid number + if (asn1.length > 1) { + index += 1; + adjust = -1; + } + } + adjustedLen = asn1.length + adjust; + + //console.warn(depth.join(ws) + '0x' + Enc.numToHex(asn1.type), index, 'len:', asn1.length, asn1); + + function parseChildren(eager) { + asn1.children = []; + //console.warn('1 len:', (2 + asn1.lengthSize + asn1.length), 'idx:', index, 'clen:', 0); + while (iters < ASN1.ELOOPN && index < (2 + asn1.length + asn1.lengthSize)) { + iters += 1; + depth.length += 1; + child = parseAsn1(buf.slice(index, index + adjustedLen), depth, eager); + depth.length -= 1; + // The numbers don't match up exactly and I don't remember why... + // probably something with adjustedLen or some such, but the tests pass + index += (2 + child.lengthSize + child.length); + //console.warn('2 len:', (2 + asn1.lengthSize + asn1.length), 'idx:', index, 'clen:', (2 + child.lengthSize + child.length)); + if (index > (2 + asn1.lengthSize + asn1.length)) { + if (!eager) { console.error(JSON.stringify(asn1, ASN1._replacer, 2)); } + throw new Error("Parse error: child value length (" + child.length + + ") is greater than remaining parent length (" + (asn1.length - index) + + " = " + asn1.length + " - " + index + ")"); + } + asn1.children.push(child); + //console.warn(depth.join(ws) + '0x' + Enc.numToHex(asn1.type), index, 'len:', asn1.length, asn1); + } + if (index !== (2 + asn1.lengthSize + asn1.length)) { + //console.warn('index:', index, 'length:', (2 + asn1.lengthSize + asn1.length)); + throw new Error("premature end-of-file"); + } + if (iters >= ASN1.ELOOPN) { throw new Error(ASN1.ELOOP); } + + delete asn1.value; + return asn1; + } + + // Recurse into types that are _always_ containers + if (-1 !== ASN1.CTYPES.indexOf(asn1.type)) { return parseChildren(eager); } + + // Return types that are _always_ values + asn1.value = buf.slice(index, index + adjustedLen); + if (-1 !== ASN1.VTYPES.indexOf(asn1.type)) { return asn1; } + + // For ambigious / unknown types, recurse and return on failure + // (and return child array size to zero) + try { return parseChildren(true); } + catch(e) { asn1.children.length = 0; return asn1; } + } + + var asn1 = parseAsn1(buf, []); + var len = buf.byteLength || buf.length; + if (len !== 2 + asn1.lengthSize + asn1.length) { + throw new Error("Length of buffer does not match length of ASN.1 sequence."); + } + return asn1; +}; +ASN1._replacer = function (k, v) { + if ('type' === k) { return '0x' + Enc.numToHex(v); } + if (v && 'value' === k) { return '0x' + Enc.bufToHex(v.data || v); } + return v; +}; + +// don't replace the full parseBlock, if it exists +PEM.parseBlock = PEM.parseBlock || function (str) { + var der = str.split(/\n/).filter(function (line) { + return !/-----/.test(line); + }).join(''); + return { bytes: Enc.base64ToBuf(der) }; +}; + +Enc.base64ToBuf = function (b64) { + return Enc.binToBuf(atob(b64)); +}; +Enc.binToBuf = function (bin) { + var arr = bin.split('').map(function (ch) { + return ch.charCodeAt(0); + }); + return 'undefined' !== typeof Uint8Array ? new Uint8Array(arr) : arr; +}; +Enc.bufToHex = function (u8) { + var hex = []; + var i, h; + var len = (u8.byteLength || u8.length); + + for (i = 0; i < len; i += 1) { + h = u8[i].toString(16); + if (h.length % 2) { h = '0' + h; } + hex.push(h); + } + + return hex.join('').toLowerCase(); +}; +Enc.numToHex = function (d) { + d = d.toString(16); + if (d.length % 2) { + return '0' + d; + } + return d; +}; + +}('undefined' !== typeof window ? window : module.exports)); +// Copyright 2018-present AJ ONeal. All rights reserved +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +(function (exports) { +'use strict'; +/*global Promise*/ + +var ASN1 = exports.ASN1; +var Enc = exports.Enc; +var PEM = exports.PEM; +var X509 = exports.x509; +var Keypairs = exports.Keypairs; + +// TODO find a way that the prior node-ish way of `module.exports = function () {}` isn't broken +var CSR = exports.CSR = function (opts) { + // We're using a Promise here to be compatible with the browser version + // which will probably use the webcrypto API for some of the conversions + return CSR._prepare(opts).then(function (opts) { + return CSR.create(opts).then(function (bytes) { + return CSR._encode(opts, bytes); + }); + }); +}; + +CSR._prepare = function (opts) { + return Promise.resolve().then(function () { + var Keypairs; + opts = JSON.parse(JSON.stringify(opts)); + + // We do a bit of extra error checking for user convenience + if (!opts) { throw new Error("You must pass options with key and domains to rsacsr"); } + if (!Array.isArray(opts.domains) || 0 === opts.domains.length) { + new Error("You must pass options.domains as a non-empty array"); + } + + // I need to check that 例.中国 is a valid domain name + if (!opts.domains.every(function (d) { + // allow punycode? xn-- + if ('string' === typeof d /*&& /\./.test(d) && !/--/.test(d)*/) { + return true; + } + })) { + throw new Error("You must pass options.domains as strings"); + } + + if (opts.jwk) { return opts; } + if (opts.key && opts.key.kty) { + opts.jwk = opts.key; + return opts; + } + if (!opts.pem && !opts.key) { + throw new Error("You must pass options.key as a JSON web key"); + } + + Keypairs = exports.Keypairs; + if (!exports.Keypairs) { + throw new Error("Keypairs.js is an optional dependency for PEM-to-JWK.\n" + + "Install it if you'd like to use it:\n" + + "\tnpm install --save rasha\n" + + "Otherwise supply a jwk as the private key." + ); + } + + return Keypairs.import({ pem: opts.pem || opts.key }).then(function (pair) { + opts.jwk = pair.private; + return opts; + }); + }); +}; + +CSR._encode = function (opts, bytes) { + if ('der' === (opts.encoding||'').toLowerCase()) { + return bytes; + } + return PEM.packBlock({ + type: "CERTIFICATE REQUEST" + , bytes: bytes /* { jwk: jwk, domains: opts.domains } */ + }); +}; + +CSR.create = function createCsr(opts) { + var hex = CSR.request(opts.jwk, opts.domains); + return CSR._sign(opts.jwk, hex).then(function (csr) { + return Enc.hexToBuf(csr); + }); +}; + +// +// EC / RSA +// +CSR.request = function createCsrBodyEc(jwk, domains) { + var asn1pub; + if (/^EC/i.test(jwk.kty)) { + asn1pub = X509.packCsrEcPublicKey(jwk); + } else { + asn1pub = X509.packCsrRsaPublicKey(jwk); + } + return X509.packCsr(asn1pub, domains); +}; + +CSR._sign = function csrEcSig(jwk, request) { + // Took some tips from https://gist.github.com/codermapuche/da4f96cdb6d5ff53b7ebc156ec46a10a + // TODO will have to convert web ECDSA signatures to PEM ECDSA signatures (but RSA should be the same) + // TODO have a consistent non-private way to sign + return Keypairs._sign({ jwk: jwk, format: 'x509' }, Enc.hexToBuf(request)).then(function (sig) { + return CSR._toDer({ request: request, signature: sig, kty: jwk.kty }); + }); +}; + +CSR._toDer = function encode(opts) { + var sty; + if (/^EC/i.test(opts.kty)) { + // 1.2.840.10045.4.3.2 ecdsaWithSHA256 (ANSI X9.62 ECDSA algorithm with SHA256) + sty = ASN1('30', ASN1('06', '2a8648ce3d040302')); + } else { + // 1.2.840.113549.1.1.11 sha256WithRSAEncryption (PKCS #1) + sty = ASN1('30', ASN1('06', '2a864886f70d01010b'), ASN1('05')); + } + return ASN1('30' + // The Full CSR Request Body + , opts.request + // The Signature Type + , sty + // The Signature + , ASN1.BitStr(Enc.bufToHex(opts.signature)) + ); +}; + +X509.packCsr = function (asn1pubkey, domains) { + return ASN1('30' + // Version (0) + , ASN1.UInt('00') + + // 2.5.4.3 commonName (X.520 DN component) + , ASN1('30', ASN1('31', ASN1('30', ASN1('06', '550403'), ASN1('0c', Enc.utf8ToHex(domains[0]))))) + + // Public Key (RSA or EC) + , asn1pubkey + + // Request Body + , ASN1('a0' + , ASN1('30' + // 1.2.840.113549.1.9.14 extensionRequest (PKCS #9 via CRMF) + , ASN1('06', '2a864886f70d01090e') + , ASN1('31' + , ASN1('30' + , ASN1('30' + // 2.5.29.17 subjectAltName (X.509 extension) + , ASN1('06', '551d11') + , ASN1('04' + , ASN1('30', domains.map(function (d) { + return ASN1('82', Enc.utf8ToHex(d)); + }).join('')))))))) + ); +}; + +// TODO finish this later +// we want to parse the domains, the public key, and verify the signature +CSR._info = function (der) { + // standard base64 PEM + if ('string' === typeof der && '-' === der[0]) { + der = PEM.parseBlock(der).bytes; + } + // jose urlBase64 not-PEM + if ('string' === typeof der) { + der = Enc.base64ToBuf(der); + } + // not supporting binary-encoded bas64 + var c = ASN1.parse(der); + var kty; + // A cert has 3 parts: cert, signature meta, signature + if (c.children.length !== 3) { + throw new Error("doesn't look like a certificate request: expected 3 parts of header"); + } + var sig = c.children[2]; + if (sig.children.length) { + // ASN1/X509 EC + sig = sig.children[0]; + sig = ASN1('30', ASN1.UInt(Enc.bufToHex(sig.children[0].value)), ASN1.UInt(Enc.bufToHex(sig.children[1].value))); + sig = Enc.hexToBuf(sig); + kty = 'EC'; + } else { + // Raw RSA Sig + sig = sig.value; + kty = 'RSA'; + } + //c.children[1]; // signature type + var req = c.children[0]; + // TODO utf8 + if (4 !== req.children.length) { + throw new Error("doesn't look like a certificate request: expected 4 parts to request"); + } + // 0 null + // 1 commonName / subject + var sub = Enc.bufToBin(req.children[1].children[0].children[0].children[1].value); + // 3 public key (type, key) + //console.log('oid', Enc.bufToHex(req.children[2].children[0].children[0].value)); + var pub; + // TODO reuse ASN1 parser for these? + if ('EC' === kty) { + // throw away compression byte + pub = req.children[2].children[1].value.slice(1); + pub = { kty: kty, x: pub.slice(0, 32), y: pub.slice(32) }; + while (0 === pub.x[0]) { pub.x = pub.x.slice(1); } + while (0 === pub.y[0]) { pub.y = pub.y.slice(1); } + if ((pub.x.length || pub.x.byteLength) > 48) { + pub.crv = 'P-521'; + } else if ((pub.x.length || pub.x.byteLength) > 32) { + pub.crv = 'P-384'; + } else { + pub.crv = 'P-256'; + } + pub.x = Enc.bufToUrlBase64(pub.x); + pub.y = Enc.bufToUrlBase64(pub.y); + } else { + pub = req.children[2].children[1].children[0]; + pub = { kty: kty, n: pub.children[0].value, e: pub.children[1].value }; + while (0 === pub.n[0]) { pub.n = pub.n.slice(1); } + while (0 === pub.e[0]) { pub.e = pub.e.slice(1); } + pub.n = Enc.bufToUrlBase64(pub.n); + pub.e = Enc.bufToUrlBase64(pub.e); + } + // 4 extensions + var domains = req.children[3].children.filter(function (seq) { + // 1.2.840.113549.1.9.14 extensionRequest (PKCS #9 via CRMF) + if ('2a864886f70d01090e' === Enc.bufToHex(seq.children[0].value)) { + return true; + } + }).map(function (seq) { + return seq.children[1].children[0].children.filter(function (seq2) { + // subjectAltName (X.509 extension) + if ('551d11' === Enc.bufToHex(seq2.children[0].value)) { + return true; + } + }).map(function (seq2) { + return seq2.children[1].children[0].children.map(function (name) { + // TODO utf8 + return Enc.bufToBin(name.value); + }); + })[0]; + })[0]; + + return { + subject: sub + , altnames: domains + , jwk: pub + , signature: sig + }; +}; + +X509.packCsrRsaPublicKey = function (jwk) { + // Sequence the key + var n = ASN1.UInt(Enc.base64ToHex(jwk.n)); + var e = ASN1.UInt(Enc.base64ToHex(jwk.e)); + var asn1pub = ASN1('30', n, e); + + // Add the CSR pub key header + return ASN1('30', ASN1('30', ASN1('06', '2a864886f70d010101'), ASN1('05')), ASN1.BitStr(asn1pub)); +}; + +X509.packCsrEcPublicKey = function (jwk) { + var ecOid = X509._oids[jwk.crv]; + if (!ecOid) { + throw new Error("Unsupported namedCurve '" + jwk.crv + "'. Supported types are " + Object.keys(X509._oids)); + } + var cmp = '04'; // 04 == x+y, 02 == x-only + var hxy = ''; + // Placeholder. I'm not even sure if compression should be supported. + if (!jwk.y) { cmp = '02'; } + hxy += Enc.base64ToHex(jwk.x); + if (jwk.y) { hxy += Enc.base64ToHex(jwk.y); } + + // 1.2.840.10045.2.1 ecPublicKey + return ASN1('30', ASN1('30', ASN1('06', '2a8648ce3d0201'), ASN1('06', ecOid)), ASN1.BitStr(cmp + hxy)); +}; +X509._oids = { + // 1.2.840.10045.3.1.7 prime256v1 + // (ANSI X9.62 named elliptic curve) (06 08 - 2A 86 48 CE 3D 03 01 07) + 'P-256': '2a8648ce3d030107' + // 1.3.132.0.34 P-384 (06 05 - 2B 81 04 00 22) + // (SEC 2 recommended EC domain secp256r1) +, 'P-384': '2b81040022' + // requires more logic and isn't a recommended standard + // 1.3.132.0.35 P-521 (06 05 - 2B 81 04 00 23) + // (SEC 2 alternate P-521) +//, 'P-521': '2B 81 04 00 23' +}; + +// don't replace the full parseBlock, if it exists +PEM.parseBlock = PEM.parseBlock || function (str) { + var der = str.split(/\n/).filter(function (line) { + return !/-----/.test(line); + }).join(''); + return { bytes: Enc.base64ToBuf(der) }; +}; + +}('undefined' === typeof window ? module.exports : window)); +// Copyright 2018-present AJ ONeal. All rights reserved +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +(function (exports) { +'use strict'; +/* globals Promise */ + +var ACME = exports.ACME = {}; +//var Keypairs = exports.Keypairs || {}; +//var CSR = exports.CSR; +var Enc = exports.Enc || {}; +var Crypto = exports.Crypto || {}; + +ACME.formatPemChain = function formatPemChain(str) { + return str.trim().replace(/[\r\n]+/g, '\n').replace(/\-\n\-/g, '-\n\n-') + '\n'; +}; +ACME.splitPemChain = function splitPemChain(str) { + return str.trim().split(/[\r\n]{2,}/g).map(function (str) { + return str + '\n'; + }); +}; + + +// http-01: GET https://example.org/.well-known/acme-challenge/{{token}} => {{keyAuth}} +// dns-01: TXT _acme-challenge.example.org. => "{{urlSafeBase64(sha256(keyAuth))}}" +ACME.challengePrefixes = { + 'http-01': '/.well-known/acme-challenge' +, 'dns-01': '_acme-challenge' +}; +ACME.challengeTests = { + 'http-01': function (me, auth) { + return me.http01(auth).then(function (keyAuth) { + var err; + + // TODO limit the number of bytes that are allowed to be downloaded + if (auth.keyAuthorization === (keyAuth||'').trim()) { + return true; + } + + err = new Error( + "Error: Failed HTTP-01 Pre-Flight / Dry Run.\n" + + "curl '" + auth.challengeUrl + "'\n" + + "Expected: '" + auth.keyAuthorization + "'\n" + + "Got: '" + keyAuth + "'\n" + + "See https://git.coolaj86.com/coolaj86/acme-v2.js/issues/4" + ); + err.code = 'E_FAIL_DRY_CHALLENGE'; + return Promise.reject(err); + }); + } +, 'dns-01': function (me, auth) { + // remove leading *. on wildcard domains + return me.dns01(auth).then(function (ans) { + var err; + + if (ans.answer.some(function (txt) { + return auth.dnsAuthorization === txt.data[0]; + })) { + return true; + } + + err = new Error( + "Error: Failed DNS-01 Pre-Flight Dry Run.\n" + + "dig TXT '" + auth.dnsHost + "' does not return '" + auth.dnsAuthorization + "'\n" + + "See https://git.coolaj86.com/coolaj86/acme-v2.js/issues/4" + ); + err.code = 'E_FAIL_DRY_CHALLENGE'; + return Promise.reject(err); + }); + } +}; + +ACME._directory = function (me) { + // GET-as-GET ok + return me.request({ method: 'GET', url: me.directoryUrl, json: true }); +}; +ACME._getNonce = function (me) { + // GET-as-GET, HEAD-as-HEAD ok + var nonce; + while (true) { + nonce = me._nonces.shift(); + if (!nonce) { break; } + if (Date.now() - nonce.createdAt > (15 * 60 * 1000)) { + nonce = null; + } else { + break; + } + } + if (nonce) { return Promise.resolve(nonce.nonce); } + return me.request({ method: 'HEAD', url: me._directoryUrls.newNonce }).then(function (resp) { + return resp.headers['replay-nonce']; + }); +}; +ACME._setNonce = function (me, nonce) { + me._nonces.unshift({ nonce: nonce, createdAt: Date.now() }); +}; +// ACME RFC Section 7.3 Account Creation +/* + { + "protected": base64url({ + "alg": "ES256", + "jwk": {...}, + "nonce": "6S8IqOGY7eL2lsGoTZYifg", + "url": "https://example.com/acme/new-account" + }), + "payload": base64url({ + "termsOfServiceAgreed": true, + "onlyReturnExisting": false, + "contact": [ + "mailto:cert-admin@example.com", + "mailto:admin@example.com" + ] + }), + "signature": "RZPOnYoPs1PhjszF...-nh6X1qtOFPB519I" + } +*/ +ACME._registerAccount = function (me, options) { + if (me.debug) { console.debug('[acme-v2] accounts.create'); } + + return new Promise(function (resolve, reject) { + + function agree(tosUrl) { + var err; + if (me._tos !== tosUrl) { + err = new Error("You must agree to the ToS at '" + me._tos + "'"); + err.code = "E_AGREE_TOS"; + reject(err); + return; + } + + return ACME._importKeypair(me, options.accountKeypair).then(function (pair) { + var contact; + if (options.contact) { + contact = options.contact.slice(0); + } else if (options.email) { + contact = [ 'mailto:' + options.email ]; + } + var body = { + termsOfServiceAgreed: tosUrl === me._tos + , onlyReturnExisting: false + , contact: contact + }; + var pExt; + if (options.externalAccount) { + pExt = me.Keypairs.signJws({ + // TODO is HMAC the standard, or is this arbitrary? + secret: options.externalAccount.secret + , protected: { + alg: options.externalAccount.alg || "HS256" + , kid: options.externalAccount.id + , url: me._directoryUrls.newAccount + } + , payload: Enc.binToBuf(JSON.stringify(pair.public)) + }).then(function (jws) { + body.externalAccountBinding = jws; + return body; + }); + } else { + pExt = Promise.resolve(body); + } + return pExt.then(function (body) { + var payload = JSON.stringify(body); + return ACME._jwsRequest(me, { + options: options + , url: me._directoryUrls.newAccount + , protected: { kid: false, jwk: pair.public } + , payload: Enc.binToBuf(payload) + }).then(function (resp) { + var account = resp.body; + + if (2 !== Math.floor(resp.statusCode / 100)) { + throw new Error('account error: ' + JSON.stringify(resp.body)); + } + + var location = resp.headers.location; + // the account id url + options._kid = location; + if (me.debug) { console.debug('[DEBUG] new account location:'); } + if (me.debug) { console.debug(location); } + if (me.debug) { console.debug(resp); } + + /* + { + contact: ["mailto:jon@example.com"], + orders: "https://some-url", + status: 'valid' + } + */ + if (!account) { account = { _emptyResponse: true }; } + // https://git.coolaj86.com/coolaj86/acme-v2.js/issues/8 + if (!account.key) { account.key = {}; } + account.key.kid = options._kid; + return account; + }).then(resolve, reject); + }); + }); + } + + if (me.debug) { console.debug('[acme-v2] agreeToTerms'); } + if (1 === options.agreeToTerms.length) { + // newer promise API + return Promise.resolve(options.agreeToTerms(me._tos)).then(agree, reject); + } + else if (2 === options.agreeToTerms.length) { + // backwards compat cb API + return options.agreeToTerms(me._tos, function (err, tosUrl) { + if (!err) { agree(tosUrl); return; } + reject(err); + }); + } + else { + reject(new Error('agreeToTerms has incorrect function signature.' + + ' Should be fn(tos) { return Promise; }')); + } + }); +}; +/* + POST /acme/new-order HTTP/1.1 + Host: example.com + Content-Type: application/jose+json + + { + "protected": base64url({ + "alg": "ES256", + "kid": "https://example.com/acme/acct/1", + "nonce": "5XJ1L3lEkMG7tR6pA00clA", + "url": "https://example.com/acme/new-order" + }), + "payload": base64url({ + "identifiers": [{"type:"dns","value":"example.com"}], + "notBefore": "2016-01-01T00:00:00Z", + "notAfter": "2016-01-08T00:00:00Z" + }), + "signature": "H6ZXtGjTZyUnPeKn...wEA4TklBdh3e454g" + } +*/ +ACME._getChallenges = function (me, options, authUrl) { + if (me.debug) { console.debug('\n[DEBUG] getChallenges\n'); } + // TODO POST-as-GET + + return ACME._jwsRequest(me, { + options: options + , protected: {} + , payload: '' + , url: authUrl + }).then(function (resp) { + return resp.body; + }); +}; +ACME._wait = function wait(ms) { + return new Promise(function (resolve) { + setTimeout(resolve, (ms || 1100)); + }); +}; + +ACME._testChallengeOptions = function () { + var chToken = ACME._prnd(16); + return [ + { + "type": "http-01", + "status": "pending", + "url": "https://acme-staging-v02.example.com/0", + "token": "test-" + chToken + "-0" + } + , { + "type": "dns-01", + "status": "pending", + "url": "https://acme-staging-v02.example.com/1", + "token": "test-" + chToken + "-1", + "_wildcard": true + } + , { + "type": "tls-sni-01", + "status": "pending", + "url": "https://acme-staging-v02.example.com/2", + "token": "test-" + chToken + "-2" + } + , { + "type": "tls-alpn-01", + "status": "pending", + "url": "https://acme-staging-v02.example.com/3", + "token": "test-" + chToken + "-3" + } + ]; +}; +ACME._testChallenges = function (me, options) { + var CHECK_DELAY = 0; + return Promise.all(options.domains.map(function (identifierValue) { + // TODO we really only need one to pass, not all to pass + var challenges = ACME._testChallengeOptions(); + if (identifierValue.includes("*")) { + challenges = challenges.filter(function (ch) { return ch._wildcard; }); + } + + var challenge = ACME._chooseChallenge(options, { challenges: challenges }); + if (!challenge) { + // For example, wildcards require dns-01 and, if we don't have that, we have to bail + var enabled = options.challengeTypes.join(', ') || 'none'; + var suitable = challenges.map(function (r) { return r.type; }).join(', ') || 'none'; + return Promise.reject(new Error( + "None of the challenge types that you've enabled ( " + enabled + " )" + + " are suitable for validating the domain you've selected (" + identifierValue + ")." + + " You must enable one of ( " + suitable + " )." + )); + } + + // TODO remove skipChallengeTest + if (me.skipDryRun || me.skipChallengeTest) { + return null; + } + + if ('dns-01' === challenge.type) { + // Give the nameservers a moment to propagate + CHECK_DELAY = 1.5 * 1000; + } + + return Promise.resolve().then(function () { + var results = { + identifier: { + type: "dns" + , value: identifierValue.replace(/^\*\./, '') + } + , challenges: [ challenge ] + , expires: new Date(Date.now() + (60 * 1000)).toISOString() + , wildcard: identifierValue.includes('*.') || undefined + }; + + // The dry-run comes first in the spirit of "fail fast" + // (and protecting against challenge failure rate limits) + var dryrun = true; + return ACME._challengeToAuth(me, options, results, challenge, dryrun).then(function (auth) { + if (!me._canUse[auth.type]) { return; } + return ACME._setChallenge(me, options, auth).then(function () { + return auth; + }); + }); + }); + })).then(function (auths) { + auths = auths.filter(Boolean); + if (!auths.length) { /*skip actual test*/ return; } + return ACME._wait(CHECK_DELAY).then(function () { + return Promise.all(auths.map(function (auth) { + return ACME.challengeTests[auth.type](me, auth).then(function (result) { + // not a blocker + ACME._removeChallenge(me, options, auth); + return result; + }); + })); + }); + }); +}; +ACME._chooseChallenge = function(options, results) { + // For each of the challenge types that we support + var challenge; + options.challengeTypes.some(function (chType) { + // And for each of the challenge types that are allowed + return results.challenges.some(function (ch) { + // Check to see if there are any matches + if (ch.type === chType) { + challenge = ch; + return true; + } + }); + }); + + return challenge; +}; +ACME._challengeToAuth = function (me, options, request, challenge, dryrun) { + // we don't poison the dns cache with our dummy request + var dnsPrefix = ACME.challengePrefixes['dns-01']; + if (dryrun) { + dnsPrefix = dnsPrefix.replace('acme-challenge', 'greenlock-dryrun-' + ACME._prnd(4)); + } + + var auth = {}; + + // straight copy from the new order response + // { identifier, status, expires, challenges, wildcard } + Object.keys(request).forEach(function (key) { + auth[key] = request[key]; + }); + + // copy from the challenge we've chosen + // { type, status, url, token } + // (note the duplicate status overwrites the one above, but they should be the same) + Object.keys(challenge).forEach(function (key) { + // don't confused devs with the id url + auth[key] = challenge[key]; + }); + + // batteries-included helpers + auth.hostname = auth.identifier.value; + // because I'm not 100% clear if the wildcard identifier does or doesn't have the leading *. in all cases + auth.altname = ACME._untame(auth.identifier.value, auth.wildcard); + return ACME._importKeypair(me, options.accountKeypair).then(function (pair) { + return me.Keypairs.thumbprint({ jwk: pair.public }).then(function (thumb) { + auth.thumbprint = thumb; + // keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey)) + auth.keyAuthorization = challenge.token + '.' + auth.thumbprint; + // conflicts with ACME challenge id url is already in use, so we call this challengeUrl instead + // TODO auth.http01Url ? + auth.challengeUrl = 'http://' + auth.identifier.value + ACME.challengePrefixes['http-01'] + '/' + auth.token; + auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', ''); + + return Crypto._sha('sha256', auth.keyAuthorization).then(function (hash) { + auth.dnsAuthorization = hash; + return auth; + }); + }); + }); +}; + +ACME._untame = function (name, wild) { + if (wild) { name = '*.' + name.replace('*.', ''); } + return name; +}; + +// https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.5.1 +ACME._postChallenge = function (me, options, auth) { + var RETRY_INTERVAL = me.retryInterval || 1000; + var DEAUTH_INTERVAL = me.deauthWait || 10 * 1000; + var MAX_POLL = me.retryPoll || 8; + var MAX_PEND = me.retryPending || 4; + var count = 0; + + var altname = ACME._untame(auth.identifier.value, auth.wildcard); + + /* + POST /acme/authz/1234 HTTP/1.1 + Host: example.com + Content-Type: application/jose+json + + { + "protected": base64url({ + "alg": "ES256", + "kid": "https://example.com/acme/acct/1", + "nonce": "xWCM9lGbIyCgue8di6ueWQ", + "url": "https://example.com/acme/authz/1234" + }), + "payload": base64url({ + "status": "deactivated" + }), + "signature": "srX9Ji7Le9bjszhu...WTFdtujObzMtZcx4" + } + */ + function deactivate() { + if (me.debug) { console.debug('[acme-v2.js] deactivate:'); } + return ACME._jwsRequest(me, { + options: options + , url: auth.url + , protected: { kid: options._kid } + , payload: Enc.binToBuf(JSON.stringify({ "status": "deactivated" })) + }).then(function (resp) { + if (me.debug) { console.debug('deactivate challenge: resp.body:'); } + if (me.debug) { console.debug(resp.body); } + return ACME._wait(DEAUTH_INTERVAL); + }); + } + + function pollStatus() { + if (count >= MAX_POLL) { + return Promise.reject(new Error( + "[acme-v2] stuck in bad pending/processing state for '" + altname + "'" + )); + } + + count += 1; + + if (me.debug) { console.debug('\n[DEBUG] statusChallenge\n'); } + // TODO POST-as-GET + return me.request({ method: 'GET', url: auth.url, json: true }).then(function (resp) { + if ('processing' === resp.body.status) { + if (me.debug) { console.debug('poll: again'); } + return ACME._wait(RETRY_INTERVAL).then(pollStatus); + } + + // This state should never occur + if ('pending' === resp.body.status) { + if (count >= MAX_PEND) { + return ACME._wait(RETRY_INTERVAL).then(deactivate).then(respondToChallenge); + } + if (me.debug) { console.debug('poll: again'); } + return ACME._wait(RETRY_INTERVAL).then(respondToChallenge); + } + + if ('valid' === resp.body.status) { + if (me.debug) { console.debug('poll: valid'); } + + try { + ACME._removeChallenge(me, options, auth); + } catch(e) {} + return resp.body; + } + + var errmsg; + if (!resp.body.status) { + errmsg = "[acme-v2] (E_STATE_EMPTY) empty challenge state for '" + altname + "':"; + } + else if ('invalid' === resp.body.status) { + errmsg = "[acme-v2] (E_STATE_INVALID) challenge state for '" + altname + "': '" + resp.body.status + "'"; + } + else { + errmsg = "[acme-v2] (E_STATE_UKN) challenge state for '" + altname + "': '" + resp.body.status + "'"; + } + + return Promise.reject(new Error(errmsg)); + }); + } + + function respondToChallenge() { + if (me.debug) { console.debug('[acme-v2.js] responding to accept challenge:'); } + return ACME._jwsRequest(me, { + options: options + , url: auth.url + , protected: { kid: options._kid } + , payload: Enc.binToBuf(JSON.stringify({})) + }).then(function (resp) { + if (me.debug) { console.debug('respond to challenge: resp.body:'); } + if (me.debug) { console.debug(resp.body); } + return ACME._wait(RETRY_INTERVAL).then(pollStatus); + }); + } + + return respondToChallenge(); +}; +ACME._setChallenge = function (me, options, auth) { + return new Promise(function (resolve, reject) { + var challengers = options.challenges || {}; + var challenger = (challengers[auth.type] && challengers[auth.type].set) || options.setChallenge; + try { + if (1 === challenger.length) { + challenger(auth).then(resolve).catch(reject); + } else if (2 === challenger.length) { + challenger(auth, function (err) { + if(err) { reject(err); } else { resolve(); } + }); + } else { + // TODO remove this old backwards-compat + var challengeCb = function(err) { + if(err) { reject(err); } else { resolve(); } + }; + // for backwards compat adding extra keys without changing params length + Object.keys(auth).forEach(function (key) { + challengeCb[key] = auth[key]; + }); + if (!ACME._setChallengeWarn) { + console.warn("Please update to acme-v2 setChallenge(options) or setChallenge(options, cb)."); + console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types."); + ACME._setChallengeWarn = true; + } + challenger(auth.identifier.value, auth.token, auth.keyAuthorization, challengeCb); + } + } catch(e) { + reject(e); + } + }).then(function () { + // TODO: Do we still need this delay? Or shall we leave it to plugins to account for themselves? + var DELAY = me.setChallengeWait || 500; + if (me.debug) { console.debug('\n[DEBUG] waitChallengeDelay %s\n', DELAY); } + return ACME._wait(DELAY); + }); +}; +ACME._finalizeOrder = function (me, options, validatedDomains) { + if (me.debug) { console.debug('finalizeOrder:'); } + return ACME._generateCsrWeb64(me, options, validatedDomains).then(function (csr) { + var body = { csr: csr }; + var payload = JSON.stringify(body); + + function pollCert() { + if (me.debug) { console.debug('[acme-v2.js] pollCert:'); } + return ACME._jwsRequest(me, { + options: options + , url: options._finalize + , protected: { kid: options._kid } + , payload: Enc.binToBuf(payload) + }).then(function (resp) { + if (me.debug) { console.debug('order finalized: resp.body:'); } + if (me.debug) { console.debug(resp.body); } + + // https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.3 + // Possible values are: "pending" => ("invalid" || "ready") => "processing" => "valid" + if ('valid' === resp.body.status) { + options._expires = resp.body.expires; + options._certificate = resp.body.certificate; + + return resp.body; // return order + } + + if ('processing' === resp.body.status) { + return ACME._wait().then(pollCert); + } + + if (me.debug) { console.debug("Error: bad status:\n" + JSON.stringify(resp.body, null, 2)); } + + if ('pending' === resp.body.status) { + return Promise.reject(new Error( + "Did not finalize order: status 'pending'." + + " Best guess: You have not accepted at least one challenge for each domain:\n" + + "Requested: '" + options.domains.join(', ') + "'\n" + + "Validated: '" + validatedDomains.join(', ') + "'\n" + + JSON.stringify(resp.body, null, 2) + )); + } + + if ('invalid' === resp.body.status) { + return Promise.reject(new Error( + "Did not finalize order: status 'invalid'." + + " Best guess: One or more of the domain challenges could not be verified" + + " (or the order was canceled).\n" + + "Requested: '" + options.domains.join(', ') + "'\n" + + "Validated: '" + validatedDomains.join(', ') + "'\n" + + JSON.stringify(resp.body, null, 2) + )); + } + + if ('ready' === resp.body.status) { + return Promise.reject(new Error( + "Did not finalize order: status 'ready'." + + " Hmmm... this state shouldn't be possible here. That was the last state." + + " This one should at least be 'processing'.\n" + + "Requested: '" + options.domains.join(', ') + "'\n" + + "Validated: '" + validatedDomains.join(', ') + "'\n" + + JSON.stringify(resp.body, null, 2) + "\n\n" + + "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js" + )); + } + + return Promise.reject(new Error( + "Didn't finalize order: Unhandled status '" + resp.body.status + "'." + + " This is not one of the known statuses...\n" + + "Requested: '" + options.domains.join(', ') + "'\n" + + "Validated: '" + validatedDomains.join(', ') + "'\n" + + JSON.stringify(resp.body, null, 2) + "\n\n" + + "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js" + )); + }); + } + + return pollCert(); + }); +}; +// _kid +// registerAccount +// postChallenge +// finalizeOrder +// getCertificate +ACME._getCertificate = function (me, options) { + if (me.debug) { console.debug('[acme-v2] DEBUG get cert 1'); } + + // Lot's of error checking to inform the user of mistakes + if (!(options.challengeTypes||[]).length) { + options.challengeTypes = Object.keys(options.challenges||{}); + } + if (!options.challengeTypes.length) { + options.challengeTypes = [ options.challengeType ].filter(Boolean); + } + if (options.challengeType) { + options.challengeTypes.sort(function (a, b) { + if (a === options.challengeType) { return -1; } + if (b === options.challengeType) { return 1; } + return 0; + }); + if (options.challengeType !== options.challengeTypes[0]) { + return Promise.reject(new Error("options.challengeType is '" + options.challengeType + "'," + + " which does not exist in the supplied types '" + options.challengeTypes.join(',') + "'")); + } + } + // TODO check that all challengeTypes are represented in challenges + if (!options.challengeTypes.length) { + return Promise.reject(new Error("options.challengeTypes (string array) must be specified" + + " (and in order of preferential priority).")); + } + if (options.csr) { + // TODO validate csr signature + options._csr = me.CSR._info(options.csr); + options.domains = options._csr.altnames; + if (options._csr.subject !== options.domains[0]) { + return Promise.reject(new Error("certificate subject (commonName) does not match first altname (SAN)")); + } + } + if (!(options.domains && options.domains.length)) { + return Promise.reject(new Error("options.domains must be a list of string domain names," + + " with the first being the subject of the certificate (or options.subject must specified).")); + } + + // It's just fine if there's no account, we'll go get the key id we need via the existing key + options._kid = options._kid || options.accountKid + || (options.account && (options.account.kid + || (options.account.key && options.account.key.kid))); + if (!options._kid) { + //return Promise.reject(new Error("must include KeyID")); + // This is an idempotent request. It'll return the same account for the same public key. + return ACME._registerAccount(me, options).then(function (account) { + options._kid = account.key.kid; + // start back from the top + return ACME._getCertificate(me, options); + }); + } + + // Do a little dry-run / self-test + return ACME._testChallenges(me, options).then(function () { + if (me.debug) { console.debug('[acme-v2] certificates.create'); } + var body = { + // raw wildcard syntax MUST be used here + identifiers: options.domains.sort(function (a, b) { + // the first in the list will be the subject of the certificate, I believe (and hope) + if (!options.subject) { return 0; } + if (options.subject === a) { return -1; } + if (options.subject === b) { return 1; } + return 0; + }).map(function (hostname) { + return { type: "dns", value: hostname }; + }) + //, "notBefore": "2016-01-01T00:00:00Z" + //, "notAfter": "2016-01-08T00:00:00Z" + }; + + var payload = JSON.stringify(body); + if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); } + return ACME._jwsRequest(me, { + options: options + , url: me._directoryUrls.newOrder + , protected: { kid: options._kid } + , payload: Enc.binToBuf(payload) + }).then(function (resp) { + var location = resp.headers.location; + var setAuths; + var validAuths = []; + var auths = []; + if (me.debug) { console.debug('[ordered]', location); } // the account id url + if (me.debug) { console.debug(resp); } + options._authorizations = resp.body.authorizations; + options._order = location; + options._finalize = resp.body.finalize; + //if (me.debug) console.debug('[DEBUG] finalize:', options._finalize); return; + + if (!options._authorizations) { + return Promise.reject(new Error( + "[acme-v2.js] authorizations were not fetched for '" + options.domains.join() + "':\n" + + JSON.stringify(resp.body) + )); + } + if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); } + setAuths = options._authorizations.slice(0); + + function setNext() { + var authUrl = setAuths.shift(); + if (!authUrl) { return; } + + return ACME._getChallenges(me, options, authUrl).then(function (results) { + // var domain = options.domains[i]; // results.identifier.value + + // If it's already valid, we're golden it regardless + if (results.challenges.some(function (ch) { return 'valid' === ch.status; })) { + return setNext(); + } + + var challenge = ACME._chooseChallenge(options, results); + if (!challenge) { + // For example, wildcards require dns-01 and, if we don't have that, we have to bail + return Promise.reject(new Error( + "Server didn't offer any challenge we can handle for '" + options.domains.join() + "'." + )); + } + + return ACME._challengeToAuth(me, options, results, challenge, false).then(function (auth) { + auths.push(auth); + return ACME._setChallenge(me, options, auth).then(setNext); + }); + }); + } + + function checkNext() { + var auth = auths.shift(); + if (!auth) { return; } + + if (!me._canUse[auth.type] || me.skipChallengeTest) { + // not so much "valid" as "not invalid" + // but in this case we can't confirm either way + validAuths.push(auth); + return Promise.resolve(); + } + + return ACME.challengeTests[auth.type](me, auth).then(function () { + validAuths.push(auth); + }).then(checkNext); + } + + function challengeNext() { + var auth = validAuths.shift(); + if (!auth) { return; } + return ACME._postChallenge(me, options, auth).then(challengeNext); + } + + // First we set every challenge + // Then we ask for each challenge to be checked + // Doing otherwise would potentially cause us to poison our own DNS cache with misses + return setNext().then(checkNext).then(challengeNext).then(function () { + if (me.debug) { console.debug("[getCertificate] next.then"); } + var validatedDomains = body.identifiers.map(function (ident) { + return ident.value; + }); + + return ACME._finalizeOrder(me, options, validatedDomains); + }).then(function (order) { + if (me.debug) { console.debug('acme-v2: order was finalized'); } + // TODO POST-as-GET + return me.request({ method: 'GET', url: options._certificate, json: true }).then(function (resp) { + if (me.debug) { console.debug('acme-v2: csr submitted and cert received:'); } + // https://github.com/certbot/certbot/issues/5721 + var certsarr = ACME.splitPemChain(ACME.formatPemChain((resp.body||''))); + // cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */ + var certs = { + expires: order.expires + , identifiers: order.identifiers + //, authorizations: order.authorizations + , cert: certsarr.shift() + //, privkey: privkeyPem + , chain: certsarr.join('\n') + }; + if (me.debug) { console.debug(certs); } + return certs; + }); + }); + }); + }); +}; +ACME._generateCsrWeb64 = function (me, options, validatedDomains) { + var csr; + if (options.csr) { + csr = options.csr; + // if der, convert to base64 + if ('string' !== typeof csr) { csr = Enc.bufToUrlBase64(csr); } + // nix PEM headers, if any + if ('-' === csr[0]) { csr = csr.split(/\n+/).slice(1, -1).join(''); } + csr = Enc.base64ToUrlBase64(csr.trim().replace(/\s+/g, '')); + return Promise.resolve(csr); + } + + return ACME._importKeypair(me, options.serverKeypair || options.domainKeypair).then(function (pair) { + return me.CSR({ jwk: pair.private, domains: validatedDomains, encoding: 'der' }).then(function (der) { + return Enc.bufToUrlBase64(der); + }); + }); +}; + +ACME.create = function create(me) { + if (!me) { me = {}; } + // me.debug = true; + me.challengePrefixes = ACME.challengePrefixes; + me.Keypairs = me.Keypairs || exports.Keypairs || require('keypairs').Keypairs; + me.CSR = me.CSR || exports.CSR || require('CSR').CSR; + me._nonces = []; + me._canUse = {}; + if (!me._baseUrl) { + me._baseUrl = ""; + } + //me.Keypairs = me.Keypairs || require('keypairs'); + //me.request = me.request || require('@root/request'); + if (!me.dns01) { + me.dns01 = function (auth) { + return ACME._dns01(me, auth); + }; + } + // backwards compat + if (!me.dig) { me.dig = me.dns01; } + if (!me.http01) { + me.http01 = function (auth) { + return ACME._http01(me, auth); + }; + } + + if ('function' !== typeof me.request) { + me.request = ACME._defaultRequest; + } + + me.init = function (opts) { + function fin(dir) { + me._directoryUrls = dir; + me._tos = dir.meta.termsOfService; + return dir; + } + if (opts && opts.meta && opts.termsOfService) { + return Promise.resolve(fin(opts)); + } + if (!me.directoryUrl) { me.directoryUrl = opts; } + if ('string' !== typeof me.directoryUrl) { + throw new Error("you must supply either the ACME directory url as a string or an object of the ACME urls"); + } + var p = Promise.resolve(); + if (!me.skipChallengeTest) { + p = me.request({ url: me._baseUrl + "/api/_acme_api_/" }).then(function (resp) { + if (resp.body.success) { + me._canCheck['http-01'] = true; + me._canCheck['dns-01'] = true; + } + }).catch(function () { + // ignore + }); + } + return p.then(function () { + return ACME._directory(me).then(function (resp) { + return fin(resp.body); + }); + }); + }; + me.accounts = { + create: function (options) { + return ACME._registerAccount(me, options); + } + }; + me.certificates = { + create: function (options) { + return ACME._getCertificate(me, options); + } + }; + return me; +}; + +// Handle nonce, signing, and request altogether +ACME._jwsRequest = function (me, bigopts) { + return ACME._getNonce(me).then(function (nonce) { + bigopts.protected.nonce = nonce; + bigopts.protected.url = bigopts.url; + // protected.alg: added by Keypairs.signJws + if (!bigopts.protected.jwk) { + // protected.kid must be overwritten due to ACME's interpretation of the spec + if (!bigopts.protected.kid) { bigopts.protected.kid = bigopts.options._kid; } + } + return me.Keypairs.signJws( + { jwk: bigopts.options.accountKeypair.privateKeyJwk + , protected: bigopts.protected + , payload: bigopts.payload + } + ).then(function (jws) { + if (me.debug) { console.debug('[acme-v2] ' + bigopts.url + ':'); } + if (me.debug) { console.debug(jws); } + return ACME._request(me, { url: bigopts.url, json: jws }); + }); + }); +}; +// Handle some ACME-specific defaults +ACME._request = function (me, opts) { + if (!opts.headers) { opts.headers = {}; } + if (opts.json && true !== opts.json) { + opts.headers['Content-Type'] = 'application/jose+json'; + opts.body = JSON.stringify(opts.json); + if (!opts.method) { opts.method = 'POST'; } + } + return me.request(opts).then(function (resp) { + resp = resp.toJSON(); + if (resp.headers['replay-nonce']) { + ACME._setNonce(me, resp.headers['replay-nonce']); + } + return resp; + }); +}; +// A very generic, swappable request lib +ACME._defaultRequest = function (opts) { + // Note: normally we'd have to supply a User-Agent string, but not here in a browser + if (!opts.headers) { opts.headers = {}; } + if (opts.json) { + opts.headers.Accept = 'application/json'; + if (true !== opts.json) { opts.body = JSON.stringify(opts.json); } + } + if (!opts.method) { + opts.method = 'GET'; + if (opts.body) { opts.method = 'POST'; } + } + opts.cors = true; + return window.fetch(opts.url, opts).then(function (resp) { + var headers = {}; + var result = { statusCode: resp.status, headers: headers, toJSON: function () { return this; } }; + Array.from(resp.headers.entries()).forEach(function (h) { headers[h[0]] = h[1]; }); + if (!headers['content-type']) { + return result; + } + if (/json/.test(headers['content-type'])) { + return resp.json().then(function (json) { + result.body = json; + return result; + }); + } + return resp.text().then(function (txt) { + result.body = txt; + return result; + }); + }); +}; + +ACME._importKeypair = function (me, kp) { + var jwk = kp.privateKeyJwk; + var p; + if (jwk) { + // nix the browser jwk extras + jwk.key_ops = undefined; + jwk.ext = undefined; + p = Promise.resolve({ private: jwk, public: me.Keypairs.neuter({ jwk: jwk }) }); + } else { + p = me.Keypairs.import({ pem: kp.privateKeyPem }); + } + return p.then(function (pair) { + kp.privateKeyJwk = pair.private; + kp.publicKeyJwk = pair.public; + if (pair.public.kid) { + pair = JSON.parse(JSON.stringify(pair)); + delete pair.public.kid; + delete pair.private.kid; + } + return pair; + }); +}; + +/* +TODO +Per-Order State Params + _kty + _alg + _finalize + _expires + _certificate + _order + _authorizations +*/ + +ACME._toWebsafeBase64 = function (b64) { + return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g,""); +}; + +// In v8 this is crypto random, but we're just using it for pseudorandom +ACME._prnd = function (n) { + var rnd = ''; + while (rnd.length / 2 < n) { + var num = Math.random().toString().substr(2); + if (num.length % 2) { + num = '0' + num; + } + var pairs = num.match(/(..?)/g); + rnd += pairs.map(ACME._toHex).join(''); + } + return rnd.substr(0, n*2); +}; +ACME._toHex = function (pair) { + return parseInt(pair, 10).toString(16); +}; +ACME._dns01 = function (me, auth) { + return new me.request({ url: me._baseUrl + "/api/dns/" + auth.dnsHost + "?type=TXT" }).then(function (resp) { + var err; + if (!resp.body || !Array.isArray(resp.body.answer)) { + err = new Error("failed to get DNS response"); + console.error(err); + throw err; + } + if (!resp.body.answer.length) { + err = new Error("failed to get DNS answer record in response"); + console.error(err); + throw err; + } + return { + answer: resp.body.answer.map(function (ans) { + return { data: ans.data, ttl: ans.ttl }; + }) + }; + }); +}; +ACME._http01 = function (me, auth) { + var url = encodeURIComponent(auth.challengeUrl); + return new me.request({ url: me._baseUrl + "/api/http?url=" + url }).then(function (resp) { + return resp.body; + }); +}; +ACME._removeChallenge = function (me, options, auth) { + var challengers = options.challenges || {}; + var removeChallenge = (challengers[auth.type] && challengers[auth.type].remove) || options.removeChallenge; + if (1 === removeChallenge.length) { + removeChallenge(auth).then(function () {}, function () {}); + } else if (2 === removeChallenge.length) { + removeChallenge(auth, function (err) { return err; }); + } else { + if (!ACME._removeChallengeWarn) { + console.warn("Please update to acme-v2 removeChallenge(options) or removeChallenge(options, cb)."); + console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types."); + ACME._removeChallengeWarn = true; + } + removeChallenge(auth.request.identifier, auth.token, function () {}); + } +}; + +Enc.bufToUrlBase64 = function (u8) { + return Enc.bufToBase64(u8) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; +Enc.bufToBase64 = function (u8) { + var bin = ''; + u8.forEach(function (i) { + bin += String.fromCharCode(i); + }); + return btoa(bin); +}; + +Crypto._sha = function (sha, str) { + var encoder = new TextEncoder(); + var data = encoder.encode(str); + sha = 'SHA-' + sha.replace(/^sha-?/i, ''); + return window.crypto.subtle.digest(sha, data).then(function (hash) { + return Enc.bufToUrlBase64(new Uint8Array(hash)); + }); +}; + +}('undefined' === typeof window ? module.exports : window)); diff --git a/lib/admin/js/telebit-token.js b/lib/admin/js/telebit-token.js new file mode 100644 index 0000000..0c3fcfe --- /dev/null +++ b/lib/admin/js/telebit-token.js @@ -0,0 +1,116 @@ +;(function (exports) { +'use strict'; + +/* global Promise */ +var PromiseA; +if ('undefined' !== typeof Promise) { + PromiseA = Promise; +} else { + throw new Error("no Promise implementation defined"); +} + +var common = exports.TELEBIT || require('./lib/common.js'); + +common.authorize = common.getToken = function getToken(state, showOtp) { + state.relay = state.config.relay; + + // { _otp, config: {} } + return common.api.token(state, { + error: function (err) { console.error("[Error] common.api.token handlers.error: \n", err); return PromiseA.reject(err); } + , directory: function (dir) { + /*console.log('[directory] Telebit Relay Discovered:', dir);*/ + state._apiDirectory = dir; + return PromiseA.resolve(); + } + , tunnelUrl: function (tunnelUrl) { + //console.log('[tunnelUrl] Telebit Relay Tunnel Socket:', tunnelUrl); + state.wss = tunnelUrl; + return PromiseA.resolve(); + } + , requested: function (authReq, pollUrl) { + console.log("[requested] Pairing Requested"); + state._otp = state._otp = authReq.otp; + + if (!state.config.token && state._can_pair) { + console.info("0000".replace(/0000/g, state._otp)); + showOtp(authReq.otp, pollUrl); + } + + return PromiseA.resolve(); + } + , connect: function (pretoken) { + console.log("[connect] Enabling Pairing Locally..."); + state.config.pretoken = pretoken; + state._connecting = true; + + // This will only be saved to the session + state.config._otp = state._otp; + return common.reqLocalAsync({ url: '/api/config', method: 'POST', body: state.config, json: true }).then(function () { + console.info("waiting..."); + return PromiseA.resolve(); + }).catch(function (err) { + state._error = err; + console.error("Error while initializing config [connect]:"); + console.error(err); + return PromiseA.reject(err); + }); + } + , offer: function (token) { + //console.log("[offer] Pairing Enabled by Relay"); + state.token = token; + state.config.token = token; + if (state._error) { + return; + } + state._connecting = true; + try { + //require('jsonwebtoken').decode(token); + token = token.split('.'); + token[0] = token[0].replace(/_/g, '/').replace(/-/g, '+'); + while (token[0].length % 4) { token[0] += '='; } + btoa(token[0]); + token[1] = token[1].replace(/_/g, '/').replace(/-/g, '+'); + while (token[1].length % 4) { token[1] += '='; } + btoa(token[1]); + //console.log(require('jsonwebtoken').decode(token)); + } catch(e) { + console.warn("[warning] could not decode token"); + } + return common.reqLocalAsync({ url: '/api/config', method: 'POST', body: state.config, json: true }).then(function () { + //console.log("Pairing Enabled Locally"); + return PromiseA.resolve(); + }).catch(function (err) { + state._error = err; + console.error("Error while initializing config [offer]:"); + console.error(err); + return PromiseA.reject(err); + }); + } + , granted: function (/*_*/) { /*console.log("[grant] Pairing complete!");*/ return PromiseA.resolve(); } + , end: function () { + return common.reqLocalAsync({ url: '/api/enable', method: 'POST', body: [], json: true }).then(function () { + console.info("Success"); + + // workaround for https://github.com/nodejs/node/issues/21319 + if (state._useTty) { + setTimeout(function () { + console.info("Some fun things to try first:\n"); + console.info(" ~/telebit http ~/public"); + console.info(" ~/telebit tcp 5050"); + console.info(" ~/telebit ssh auto"); + console.info(); + console.info("Press any key to continue..."); + console.info(); + process.exit(0); + }, 0.5 * 1000); + return; + } + // end workaround + + //parseCli(state); + }); + } + }); +}; + +}('undefined' === typeof module ? window : module.exports)); diff --git a/lib/admin/js/telebit.js b/lib/admin/js/telebit.js new file mode 100644 index 0000000..fcb46be --- /dev/null +++ b/lib/admin/js/telebit.js @@ -0,0 +1,365 @@ +;(function (exports) { +'use strict'; + +var Keypairs = window.Keypairs; +var common = exports.TELEBIT = {}; +common.debug = true; + +/* global Promise */ +var PromiseA; +if ('undefined' !== typeof Promise) { + PromiseA = Promise; +} else { + throw new Error("no Promise implementation defined"); +} + +/*globals AbortController*/ +if ('undefined' !== typeof fetch) { + common._requestAsync = function (opts) { + // funnel requests through the local server + // (avoid CORS, for now) + var relayOpts = { + url: '/api/relay' + , method: 'POST' + , headers: { + 'Content-Type': 'application/json' + , 'Accepts': 'application/json' + } + , body: JSON.stringify(opts) + }; + var controller = new AbortController(); + var tok = setTimeout(function () { + controller.abort(); + }, 4000); + if (!relayOpts) { + relayOpts = {}; + } + relayOpts.signal = controller.signal; + return window.fetch(relayOpts.url, relayOpts).then(function (resp) { + clearTimeout(tok); + return resp.json().then(function (json) { + if (json.error) { + return PromiseA.reject(new Error(json.error && json.error.message || JSON.stringify(json.error))); + } + return json; + }); + }); + }; + common._reqLocalAsync = function (opts) { + if (!opts) { opts = {}; } + if (opts.json && true !== opts.json) { + opts.body = opts.json; + opts.json = true; + } + if (opts.json) { + if (!opts.headers) { opts.headers = {}; } + if (opts.body) { + opts.headers['Content-Type'] = 'application/json'; + if ('string' !== typeof opts.body) { + opts.body = JSON.stringify(opts.body); + } + } else { + opts.headers.Accepts = 'application/json'; + } + } + var controller = new AbortController(); + var tok = setTimeout(function () { + controller.abort(); + }, 4000); + opts.signal = controller.signal; + return window.fetch(opts.url, opts).then(function (resp) { + clearTimeout(tok); + return resp.json().then(function (json) { + var headers = {}; + resp.headers.forEach(function (k, v) { + headers[k] = v; + }); + return { statusCode: resp.status, headers: headers, body: json }; + }); + }); + }; +} else { + common._requestAsync = require('util').promisify(require('@root/request')); + common._reqLocalAsync = require('util').promisify(require('@root/request')); +} +common._sign = function (opts) { + var p; + if ('POST' === opts.method || opts.json) { + p = Keypairs.signJws({ jwk: opts.key || common._key, payload: opts.json || {} }).then(function (jws) { + opts.json = jws; + }); + } else { + p = Keypairs.signJwt({ jwk: opts.key || common._key, claims: { iss: false, exp: '60s' } }).then(function (jwt) { + if (!opts.headers) { opts.headers = {}; } + opts.headers.Authorization = 'Bearer ' + jwt; + }); + } + return p.then(function () { + return opts; + }); +}; +common.requestAsync = function (opts) { + return common._sign(opts).then(function (opts) { + return common._requestAsync(opts); + }); +}; +common.reqLocalAsync = function (opts) { + return common._sign(opts).then(function (opts) { + return common._reqLocalAsync(opts); + }); +}; + +common.parseUrl = function (hostname) { + // add scheme, if missing + if (!/:\/\//.test(hostname)) { + hostname = 'https://' + hostname; + } + var location = new URL(hostname); + hostname = location.hostname + (location.port ? ':' + location.port : ''); + hostname = location.protocol.replace(/https?/, 'https') + '//' + hostname + location.pathname; + return hostname; +}; +common.parseHostname = function (hostname) { + var location = {}; + try { + location = new URL(hostname); + } catch(e) { + // ignore + } + if (!location.protocol || /\./.test(location.protocol)) { + hostname = 'https://' + hostname; + location = new URL(hostname); + } + //hostname = location.hostname + (location.port ? ':' + location.port : ''); + //hostname = location.protocol.replace(/https?/, 'https') + '//' + hostname + location.pathname; + return location.hostname; +}; + +common.apiDirectory = '_apis/telebit.cloud/index.json'; + +common.otp = function getOtp() { + return Math.round(Math.random() * 9999).toString().padStart(4, '0'); +}; +common.signToken = function (state) { + var JWT = require('./jwt.js'); + var tokenData = { + domains: Object.keys(state.config.servernames || {}).filter(function (name) { + return /\./.test(name); + }) + , ports: Object.keys(state.config.ports || {}).filter(function (port) { + port = parseInt(port, 10); + return port > 0 && port <= 65535; + }) + , aud: state._relayUrl + , iss: Math.round(Date.now() / 1000) + }; + + return JWT.sign(tokenData, state.config.secret); +}; +common.promiseTimeout = function (ms) { + var tok; + var p = new PromiseA(function (resolve) { + tok = setTimeout(function () { + resolve(); + }, ms); + }); + p.cancel = function () { + clearTimeout(tok); + }; + return p; +}; +common.api = {}; +common.api.directory = function (state) { + console.log('[DEBUG] state:'); + console.log(state); + state._relayUrl = common.parseUrl(state.relay); + if (!state._relays) { state._relays = {}; } + if (state._relays[state._relayUrl]) { + return PromiseA.resolve(state._relays[state._relayUrl]); + } + return common.requestAsync({ url: state._relayUrl + common.apiDirectory, json: true }).then(function (resp) { + var dir = resp.body; + state._relays[state._relayUrl] = dir; + return dir; + }); +}; +common.api._parseWss = function (state, dir) { + if (!dir || !dir.api_host) { + dir = { api_host: ':hostname', tunnel: { method: "wss", pathname: "" } }; + } + state._relayHostname = common.parseHostname(state.relay); + return dir.tunnel.method + '://' + dir.api_host.replace(/:hostname/g, state._relayHostname) + dir.tunnel.pathname; +}; +common.api.wss = function (state) { + return common.api.directory(state).then(function (dir) { + return common.api._parseWss(state, dir); + }); +}; +common.api.token = function (state, handlers) { + + var firstReady = true; + function pollStatus(req) { + if (common.debug) { console.log('[debug] pollStatus called'); } + if (common.debug) { console.log(req); } + return common.requestAsync(req).then(function checkLocation(resp) { + var body = resp.body; + if (common.debug) { console.log('[debug] checkLocation'); } + if (common.debug) { console.log(body); } + // pending, try again + if ('pending' === body.status && resp.headers.location) { + if (common.debug) { console.log('[debug] pending'); } + return common.promiseTimeout(2 * 1000).then(function () { + return pollStatus({ url: resp.headers.location, json: true }); + }); + } else if ('ready' === body.status) { + if (common.debug) { console.log('[debug] ready'); } + if (firstReady) { + if (common.debug) { console.log('[debug] first ready'); } + firstReady = false; + // falls through on purpose + PromiseA.resolve(handlers.offer(body.access_token)).then(function () { + /*ignore*/ + }); + } + return common.promiseTimeout(2 * 1000).then(function () { + return pollStatus(req); + }); + } else if ('complete' === body.status) { + if (common.debug) { console.log('[debug] complete'); } + return PromiseA.resolve(handlers.granted(null)).then(function () { + return PromiseA.resolve(handlers.end(null)).then(function () {}); + }); + } else { + if (common.debug) { console.log('[debug] bad status'); } + var err = new Error("Bad State:" + body.status); + err._request = req; + return PromiseA.reject(err); + } + }).catch(function (err) { + if (common.debug) { console.log('[debug] pollStatus error'); } + err._request = req; + err._hint = '[telebitd.js] pair request'; + return PromiseA.resolve(handlers.error(err)).then(function () {}); + }); + } + + // directory, requested, connect, tunnelUrl, offer, granted, end + function requestAuth(dir) { + if (common.debug) { console.log('[debug] after dir'); } + state.wss = common.api._parseWss(state, dir); + + return PromiseA.resolve(handlers.tunnelUrl(state.wss)).then(function () { + if (common.debug) { console.log('[debug] after tunnelUrl'); } + if (state.config.secret /* && !state.config.token */) { + // TODO make token here in the browser + //state.config._token = common.signToken(state); + } + state.token = state.token || state.config.token || state.config._token; + if (state.token) { + if (common.debug) { console.log('[debug] token via token or secret'); } + // { token, pretoken } + return PromiseA.resolve(handlers.connect(state.token)).then(function () { + return PromiseA.resolve(handlers.end(null)); + }); + } + + if (!dir.pair_request) { + if (common.debug) { console.log('[debug] no dir, connect'); } + return PromiseA.resolve(handlers.error(new Error("No token found or generated, and no pair_request api found."))); + } + + // TODO sign token with own private key, including public key and thumbprint + // (much like ACME JOSE account) + // TODO handle agree + var otp = state._otp; // common.otp(); + var authReq = { + subject: state.config.email + , subject_scheme: 'mailto' + // TODO create domains list earlier + , scope: (state.config._servernames || Object.keys(state.config.servernames || {})) + .concat(state.config._ports || Object.keys(state.config.ports || {})).join(',') + , otp: otp + // TODO make call to daemon for this info beforehand + /* + , hostname: os.hostname() + // Used for User-Agent + , os_type: os.type() + , os_platform: os.platform() + , os_release: os.release() + , os_arch: os.arch() + */ + }; + var pairRequestUrl = new URL(dir.pair_request.pathname, 'https://' + dir.api_host.replace(/:hostname/g, state._relayHostname)); + console.log('pairRequestUrl:', pairRequestUrl); + //console.log('pairRequestUrl:', JSON.stringify(pairRequestUrl.toJSON())); + var req = { + // WHATWG URL defines .toJSON() but, of course, it's not implemented + // because... why would we implement JavaScript objects in the DOM + // when we can have perfectly incompatible non-JS objects? + url: { + host: pairRequestUrl.host + , hostname: pairRequestUrl.hostname + , href: pairRequestUrl.href + , pathname: pairRequestUrl.pathname + // because why wouldn't node require 'path' on a json object and accept 'pathname' on a URL object... + // https://twitter.com/coolaj86/status/1053947919890403328 + , path: pairRequestUrl.pathname + , port: pairRequestUrl.port || null + , protocol: pairRequestUrl.protocol + , search: pairRequestUrl.search || null + } + , method: dir.pair_request.method + , json: authReq + }; + + return common.requestAsync(req).then(function doFirst(resp) { + var body = resp.body; + if (common.debug) { console.log('[debug] first req'); } + if (!body.access_token && !body.jwt) { + return PromiseA.reject(new Error("something wrong with pre-authorization request")); + } + return PromiseA.resolve(handlers.requested(authReq, resp.headers.location)).then(function () { + return PromiseA.resolve(handlers.connect(body.access_token || body.jwt)).then(function () { + var err; + if (!resp.headers.location) { + err = new Error("bad authentication request response"); + err._resp = resp.toJSON && resp.toJSON(); + return PromiseA.resolve(handlers.error(err)).then(function () {}); + } + return common.promiseTimeout(2 * 1000).then(function () { + return pollStatus({ url: resp.headers.location, json: true }); + }); + }); + }); + }).catch(function (err) { + if (common.debug) { console.log('[debug] gotoFirst error'); } + err._request = req; + err._hint = '[telebitd.js] pair request'; + return PromiseA.resolve(handlers.error(err)).then(function () {}); + }); + }); + } + + if (state.pollUrl) { + return pollStatus({ url: state.pollUrl, json: true }); + } + + // backwards compat (TODO verify we can remove this) + var failoverDir = '{ "api_host": ":hostname", "tunnel": { "method": "wss", "pathname": "" } }'; + return common.api.directory(state).then(function (dir) { + console.log('[debug] [directory]', dir); + if (!dir.api_host) { dir = JSON.parse(failoverDir); } + return dir; + }).catch(function (err) { + console.warn('[warn] [directory] fetch fail, using failover'); + console.warn(err); + return JSON.parse(failoverDir); + }).then(function (dir) { + return PromiseA.resolve(handlers.directory(dir)).then(function () { + console.log('[debug] [directory]', dir); + return requestAuth(dir); + }); + }); +}; + +}('undefined' !== typeof module ? module.exports : window)); diff --git a/lib/admin/js/vue.js b/lib/admin/js/vue.js new file mode 100644 index 0000000..ef171b9 --- /dev/null +++ b/lib/admin/js/vue.js @@ -0,0 +1,10947 @@ +/*! + * Vue.js v2.5.17 + * (c) 2014-2018 Evan You + * Released under the MIT License. + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global.Vue = factory()); +}(this, (function () { 'use strict'; + +/* */ + +var emptyObject = Object.freeze({}); + +// these helpers produces better vm code in JS engines due to their +// explicitness and function inlining +function isUndef (v) { + return v === undefined || v === null +} + +function isDef (v) { + return v !== undefined && v !== null +} + +function isTrue (v) { + return v === true +} + +function isFalse (v) { + return v === false +} + +/** + * Check if value is primitive + */ +function isPrimitive (value) { + return ( + typeof value === 'string' || + typeof value === 'number' || + // $flow-disable-line + typeof value === 'symbol' || + typeof value === 'boolean' + ) +} + +/** + * Quick object check - this is primarily used to tell + * Objects from primitive values when we know the value + * is a JSON-compliant type. + */ +function isObject (obj) { + return obj !== null && typeof obj === 'object' +} + +/** + * Get the raw type string of a value e.g. [object Object] + */ +var _toString = Object.prototype.toString; + +function toRawType (value) { + return _toString.call(value).slice(8, -1) +} + +/** + * Strict object type check. Only returns true + * for plain JavaScript objects. + */ +function isPlainObject (obj) { + return _toString.call(obj) === '[object Object]' +} + +function isRegExp (v) { + return _toString.call(v) === '[object RegExp]' +} + +/** + * Check if val is a valid array index. + */ +function isValidArrayIndex (val) { + var n = parseFloat(String(val)); + return n >= 0 && Math.floor(n) === n && isFinite(val) +} + +/** + * Convert a value to a string that is actually rendered. + */ +function toString (val) { + return val == null + ? '' + : typeof val === 'object' + ? JSON.stringify(val, null, 2) + : String(val) +} + +/** + * Convert a input value to a number for persistence. + * If the conversion fails, return original string. + */ +function toNumber (val) { + var n = parseFloat(val); + return isNaN(n) ? val : n +} + +/** + * Make a map and return a function for checking if a key + * is in that map. + */ +function makeMap ( + str, + expectsLowerCase +) { + var map = Object.create(null); + var list = str.split(','); + for (var i = 0; i < list.length; i++) { + map[list[i]] = true; + } + return expectsLowerCase + ? function (val) { return map[val.toLowerCase()]; } + : function (val) { return map[val]; } +} + +/** + * Check if a tag is a built-in tag. + */ +var isBuiltInTag = makeMap('slot,component', true); + +/** + * Check if a attribute is a reserved attribute. + */ +var isReservedAttribute = makeMap('key,ref,slot,slot-scope,is'); + +/** + * Remove an item from an array + */ +function remove (arr, item) { + if (arr.length) { + var index = arr.indexOf(item); + if (index > -1) { + return arr.splice(index, 1) + } + } +} + +/** + * Check whether the object has the property. + */ +var hasOwnProperty = Object.prototype.hasOwnProperty; +function hasOwn (obj, key) { + return hasOwnProperty.call(obj, key) +} + +/** + * Create a cached version of a pure function. + */ +function cached (fn) { + var cache = Object.create(null); + return (function cachedFn (str) { + var hit = cache[str]; + return hit || (cache[str] = fn(str)) + }) +} + +/** + * Camelize a hyphen-delimited string. + */ +var camelizeRE = /-(\w)/g; +var camelize = cached(function (str) { + return str.replace(camelizeRE, function (_, c) { return c ? c.toUpperCase() : ''; }) +}); + +/** + * Capitalize a string. + */ +var capitalize = cached(function (str) { + return str.charAt(0).toUpperCase() + str.slice(1) +}); + +/** + * Hyphenate a camelCase string. + */ +var hyphenateRE = /\B([A-Z])/g; +var hyphenate = cached(function (str) { + return str.replace(hyphenateRE, '-$1').toLowerCase() +}); + +/** + * Simple bind polyfill for environments that do not support it... e.g. + * PhantomJS 1.x. Technically we don't need this anymore since native bind is + * now more performant in most browsers, but removing it would be breaking for + * code that was able to run in PhantomJS 1.x, so this must be kept for + * backwards compatibility. + */ + +/* istanbul ignore next */ +function polyfillBind (fn, ctx) { + function boundFn (a) { + var l = arguments.length; + return l + ? l > 1 + ? fn.apply(ctx, arguments) + : fn.call(ctx, a) + : fn.call(ctx) + } + + boundFn._length = fn.length; + return boundFn +} + +function nativeBind (fn, ctx) { + return fn.bind(ctx) +} + +var bind = Function.prototype.bind + ? nativeBind + : polyfillBind; + +/** + * Convert an Array-like object to a real Array. + */ +function toArray (list, start) { + start = start || 0; + var i = list.length - start; + var ret = new Array(i); + while (i--) { + ret[i] = list[i + start]; + } + return ret +} + +/** + * Mix properties into target object. + */ +function extend (to, _from) { + for (var key in _from) { + to[key] = _from[key]; + } + return to +} + +/** + * Merge an Array of Objects into a single Object. + */ +function toObject (arr) { + var res = {}; + for (var i = 0; i < arr.length; i++) { + if (arr[i]) { + extend(res, arr[i]); + } + } + return res +} + +/** + * Perform no operation. + * Stubbing args to make Flow happy without leaving useless transpiled code + * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/) + */ +function noop (a, b, c) {} + +/** + * Always return false. + */ +var no = function (a, b, c) { return false; }; + +/** + * Return same value + */ +var identity = function (_) { return _; }; + +/** + * Generate a static keys string from compiler modules. + */ +function genStaticKeys (modules) { + return modules.reduce(function (keys, m) { + return keys.concat(m.staticKeys || []) + }, []).join(',') +} + +/** + * Check if two values are loosely equal - that is, + * if they are plain objects, do they have the same shape? + */ +function looseEqual (a, b) { + if (a === b) { return true } + var isObjectA = isObject(a); + var isObjectB = isObject(b); + if (isObjectA && isObjectB) { + try { + var isArrayA = Array.isArray(a); + var isArrayB = Array.isArray(b); + if (isArrayA && isArrayB) { + return a.length === b.length && a.every(function (e, i) { + return looseEqual(e, b[i]) + }) + } else if (!isArrayA && !isArrayB) { + var keysA = Object.keys(a); + var keysB = Object.keys(b); + return keysA.length === keysB.length && keysA.every(function (key) { + return looseEqual(a[key], b[key]) + }) + } else { + /* istanbul ignore next */ + return false + } + } catch (e) { + /* istanbul ignore next */ + return false + } + } else if (!isObjectA && !isObjectB) { + return String(a) === String(b) + } else { + return false + } +} + +function looseIndexOf (arr, val) { + for (var i = 0; i < arr.length; i++) { + if (looseEqual(arr[i], val)) { return i } + } + return -1 +} + +/** + * Ensure a function is called only once. + */ +function once (fn) { + var called = false; + return function () { + if (!called) { + called = true; + fn.apply(this, arguments); + } + } +} + +var SSR_ATTR = 'data-server-rendered'; + +var ASSET_TYPES = [ + 'component', + 'directive', + 'filter' +]; + +var LIFECYCLE_HOOKS = [ + 'beforeCreate', + 'created', + 'beforeMount', + 'mounted', + 'beforeUpdate', + 'updated', + 'beforeDestroy', + 'destroyed', + 'activated', + 'deactivated', + 'errorCaptured' +]; + +/* */ + +var config = ({ + /** + * Option merge strategies (used in core/util/options) + */ + // $flow-disable-line + optionMergeStrategies: Object.create(null), + + /** + * Whether to suppress warnings. + */ + silent: false, + + /** + * Show production mode tip message on boot? + */ + productionTip: "development" !== 'production', + + /** + * Whether to enable devtools + */ + devtools: "development" !== 'production', + + /** + * Whether to record perf + */ + performance: false, + + /** + * Error handler for watcher errors + */ + errorHandler: null, + + /** + * Warn handler for watcher warns + */ + warnHandler: null, + + /** + * Ignore certain custom elements + */ + ignoredElements: [], + + /** + * Custom user key aliases for v-on + */ + // $flow-disable-line + keyCodes: Object.create(null), + + /** + * Check if a tag is reserved so that it cannot be registered as a + * component. This is platform-dependent and may be overwritten. + */ + isReservedTag: no, + + /** + * Check if an attribute is reserved so that it cannot be used as a component + * prop. This is platform-dependent and may be overwritten. + */ + isReservedAttr: no, + + /** + * Check if a tag is an unknown element. + * Platform-dependent. + */ + isUnknownElement: no, + + /** + * Get the namespace of an element + */ + getTagNamespace: noop, + + /** + * Parse the real tag name for the specific platform. + */ + parsePlatformTagName: identity, + + /** + * Check if an attribute must be bound using property, e.g. value + * Platform-dependent. + */ + mustUseProp: no, + + /** + * Exposed for legacy reasons + */ + _lifecycleHooks: LIFECYCLE_HOOKS +}) + +/* */ + +/** + * Check if a string starts with $ or _ + */ +function isReserved (str) { + var c = (str + '').charCodeAt(0); + return c === 0x24 || c === 0x5F +} + +/** + * Define a property. + */ +function def (obj, key, val, enumerable) { + Object.defineProperty(obj, key, { + value: val, + enumerable: !!enumerable, + writable: true, + configurable: true + }); +} + +/** + * Parse simple path. + */ +var bailRE = /[^\w.$]/; +function parsePath (path) { + if (bailRE.test(path)) { + return + } + var segments = path.split('.'); + return function (obj) { + for (var i = 0; i < segments.length; i++) { + if (!obj) { return } + obj = obj[segments[i]]; + } + return obj + } +} + +/* */ + +// can we use __proto__? +var hasProto = '__proto__' in {}; + +// Browser environment sniffing +var inBrowser = typeof window !== 'undefined'; +var inWeex = typeof WXEnvironment !== 'undefined' && !!WXEnvironment.platform; +var weexPlatform = inWeex && WXEnvironment.platform.toLowerCase(); +var UA = inBrowser && window.navigator.userAgent.toLowerCase(); +var isIE = UA && /msie|trident/.test(UA); +var isIE9 = UA && UA.indexOf('msie 9.0') > 0; +var isEdge = UA && UA.indexOf('edge/') > 0; +var isAndroid = (UA && UA.indexOf('android') > 0) || (weexPlatform === 'android'); +var isIOS = (UA && /iphone|ipad|ipod|ios/.test(UA)) || (weexPlatform === 'ios'); +var isChrome = UA && /chrome\/\d+/.test(UA) && !isEdge; + +// Firefox has a "watch" function on Object.prototype... +var nativeWatch = ({}).watch; + +var supportsPassive = false; +if (inBrowser) { + try { + var opts = {}; + Object.defineProperty(opts, 'passive', ({ + get: function get () { + /* istanbul ignore next */ + supportsPassive = true; + } + })); // https://github.com/facebook/flow/issues/285 + window.addEventListener('test-passive', null, opts); + } catch (e) {} +} + +// this needs to be lazy-evaled because vue may be required before +// vue-server-renderer can set VUE_ENV +var _isServer; +var isServerRendering = function () { + if (_isServer === undefined) { + /* istanbul ignore if */ + if (!inBrowser && !inWeex && typeof global !== 'undefined') { + // detect presence of vue-server-renderer and avoid + // Webpack shimming the process + _isServer = global['process'].env.VUE_ENV === 'server'; + } else { + _isServer = false; + } + } + return _isServer +}; + +// detect devtools +var devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__; + +/* istanbul ignore next */ +function isNative (Ctor) { + return typeof Ctor === 'function' && /native code/.test(Ctor.toString()) +} + +var hasSymbol = + typeof Symbol !== 'undefined' && isNative(Symbol) && + typeof Reflect !== 'undefined' && isNative(Reflect.ownKeys); + +var _Set; +/* istanbul ignore if */ // $flow-disable-line +if (typeof Set !== 'undefined' && isNative(Set)) { + // use native Set when available. + _Set = Set; +} else { + // a non-standard Set polyfill that only works with primitive keys. + _Set = (function () { + function Set () { + this.set = Object.create(null); + } + Set.prototype.has = function has (key) { + return this.set[key] === true + }; + Set.prototype.add = function add (key) { + this.set[key] = true; + }; + Set.prototype.clear = function clear () { + this.set = Object.create(null); + }; + + return Set; + }()); +} + +/* */ + +var warn = noop; +var tip = noop; +var generateComponentTrace = (noop); // work around flow check +var formatComponentName = (noop); + +{ + var hasConsole = typeof console !== 'undefined'; + var classifyRE = /(?:^|[-_])(\w)/g; + var classify = function (str) { return str + .replace(classifyRE, function (c) { return c.toUpperCase(); }) + .replace(/[-_]/g, ''); }; + + warn = function (msg, vm) { + var trace = vm ? generateComponentTrace(vm) : ''; + + if (config.warnHandler) { + config.warnHandler.call(null, msg, vm, trace); + } else if (hasConsole && (!config.silent)) { + console.error(("[Vue warn]: " + msg + trace)); + } + }; + + tip = function (msg, vm) { + if (hasConsole && (!config.silent)) { + console.warn("[Vue tip]: " + msg + ( + vm ? generateComponentTrace(vm) : '' + )); + } + }; + + formatComponentName = function (vm, includeFile) { + if (vm.$root === vm) { + return '' + } + var options = typeof vm === 'function' && vm.cid != null + ? vm.options + : vm._isVue + ? vm.$options || vm.constructor.options + : vm || {}; + var name = options.name || options._componentTag; + var file = options.__file; + if (!name && file) { + var match = file.match(/([^/\\]+)\.vue$/); + name = match && match[1]; + } + + return ( + (name ? ("<" + (classify(name)) + ">") : "") + + (file && includeFile !== false ? (" at " + file) : '') + ) + }; + + var repeat = function (str, n) { + var res = ''; + while (n) { + if (n % 2 === 1) { res += str; } + if (n > 1) { str += str; } + n >>= 1; + } + return res + }; + + generateComponentTrace = function (vm) { + if (vm._isVue && vm.$parent) { + var tree = []; + var currentRecursiveSequence = 0; + while (vm) { + if (tree.length > 0) { + var last = tree[tree.length - 1]; + if (last.constructor === vm.constructor) { + currentRecursiveSequence++; + vm = vm.$parent; + continue + } else if (currentRecursiveSequence > 0) { + tree[tree.length - 1] = [last, currentRecursiveSequence]; + currentRecursiveSequence = 0; + } + } + tree.push(vm); + vm = vm.$parent; + } + return '\n\nfound in\n\n' + tree + .map(function (vm, i) { return ("" + (i === 0 ? '---> ' : repeat(' ', 5 + i * 2)) + (Array.isArray(vm) + ? ((formatComponentName(vm[0])) + "... (" + (vm[1]) + " recursive calls)") + : formatComponentName(vm))); }) + .join('\n') + } else { + return ("\n\n(found in " + (formatComponentName(vm)) + ")") + } + }; +} + +/* */ + + +var uid = 0; + +/** + * A dep is an observable that can have multiple + * directives subscribing to it. + */ +var Dep = function Dep () { + this.id = uid++; + this.subs = []; +}; + +Dep.prototype.addSub = function addSub (sub) { + this.subs.push(sub); +}; + +Dep.prototype.removeSub = function removeSub (sub) { + remove(this.subs, sub); +}; + +Dep.prototype.depend = function depend () { + if (Dep.target) { + Dep.target.addDep(this); + } +}; + +Dep.prototype.notify = function notify () { + // stabilize the subscriber list first + var subs = this.subs.slice(); + for (var i = 0, l = subs.length; i < l; i++) { + subs[i].update(); + } +}; + +// the current target watcher being evaluated. +// this is globally unique because there could be only one +// watcher being evaluated at any time. +Dep.target = null; +var targetStack = []; + +function pushTarget (_target) { + if (Dep.target) { targetStack.push(Dep.target); } + Dep.target = _target; +} + +function popTarget () { + Dep.target = targetStack.pop(); +} + +/* */ + +var VNode = function VNode ( + tag, + data, + children, + text, + elm, + context, + componentOptions, + asyncFactory +) { + this.tag = tag; + this.data = data; + this.children = children; + this.text = text; + this.elm = elm; + this.ns = undefined; + this.context = context; + this.fnContext = undefined; + this.fnOptions = undefined; + this.fnScopeId = undefined; + this.key = data && data.key; + this.componentOptions = componentOptions; + this.componentInstance = undefined; + this.parent = undefined; + this.raw = false; + this.isStatic = false; + this.isRootInsert = true; + this.isComment = false; + this.isCloned = false; + this.isOnce = false; + this.asyncFactory = asyncFactory; + this.asyncMeta = undefined; + this.isAsyncPlaceholder = false; +}; + +var prototypeAccessors = { child: { configurable: true } }; + +// DEPRECATED: alias for componentInstance for backwards compat. +/* istanbul ignore next */ +prototypeAccessors.child.get = function () { + return this.componentInstance +}; + +Object.defineProperties( VNode.prototype, prototypeAccessors ); + +var createEmptyVNode = function (text) { + if ( text === void 0 ) text = ''; + + var node = new VNode(); + node.text = text; + node.isComment = true; + return node +}; + +function createTextVNode (val) { + return new VNode(undefined, undefined, undefined, String(val)) +} + +// optimized shallow clone +// used for static nodes and slot nodes because they may be reused across +// multiple renders, cloning them avoids errors when DOM manipulations rely +// on their elm reference. +function cloneVNode (vnode) { + var cloned = new VNode( + vnode.tag, + vnode.data, + vnode.children, + vnode.text, + vnode.elm, + vnode.context, + vnode.componentOptions, + vnode.asyncFactory + ); + cloned.ns = vnode.ns; + cloned.isStatic = vnode.isStatic; + cloned.key = vnode.key; + cloned.isComment = vnode.isComment; + cloned.fnContext = vnode.fnContext; + cloned.fnOptions = vnode.fnOptions; + cloned.fnScopeId = vnode.fnScopeId; + cloned.isCloned = true; + return cloned +} + +/* + * not type checking this file because flow doesn't play well with + * dynamically accessing methods on Array prototype + */ + +var arrayProto = Array.prototype; +var arrayMethods = Object.create(arrayProto); + +var methodsToPatch = [ + 'push', + 'pop', + 'shift', + 'unshift', + 'splice', + 'sort', + 'reverse' +]; + +/** + * Intercept mutating methods and emit events + */ +methodsToPatch.forEach(function (method) { + // cache original method + var original = arrayProto[method]; + def(arrayMethods, method, function mutator () { + var args = [], len = arguments.length; + while ( len-- ) args[ len ] = arguments[ len ]; + + var result = original.apply(this, args); + var ob = this.__ob__; + var inserted; + switch (method) { + case 'push': + case 'unshift': + inserted = args; + break + case 'splice': + inserted = args.slice(2); + break + } + if (inserted) { ob.observeArray(inserted); } + // notify change + ob.dep.notify(); + return result + }); +}); + +/* */ + +var arrayKeys = Object.getOwnPropertyNames(arrayMethods); + +/** + * In some cases we may want to disable observation inside a component's + * update computation. + */ +var shouldObserve = true; + +function toggleObserving (value) { + shouldObserve = value; +} + +/** + * Observer class that is attached to each observed + * object. Once attached, the observer converts the target + * object's property keys into getter/setters that + * collect dependencies and dispatch updates. + */ +var Observer = function Observer (value) { + this.value = value; + this.dep = new Dep(); + this.vmCount = 0; + def(value, '__ob__', this); + if (Array.isArray(value)) { + var augment = hasProto + ? protoAugment + : copyAugment; + augment(value, arrayMethods, arrayKeys); + this.observeArray(value); + } else { + this.walk(value); + } +}; + +/** + * Walk through each property and convert them into + * getter/setters. This method should only be called when + * value type is Object. + */ +Observer.prototype.walk = function walk (obj) { + var keys = Object.keys(obj); + for (var i = 0; i < keys.length; i++) { + defineReactive(obj, keys[i]); + } +}; + +/** + * Observe a list of Array items. + */ +Observer.prototype.observeArray = function observeArray (items) { + for (var i = 0, l = items.length; i < l; i++) { + observe(items[i]); + } +}; + +// helpers + +/** + * Augment an target Object or Array by intercepting + * the prototype chain using __proto__ + */ +function protoAugment (target, src, keys) { + /* eslint-disable no-proto */ + target.__proto__ = src; + /* eslint-enable no-proto */ +} + +/** + * Augment an target Object or Array by defining + * hidden properties. + */ +/* istanbul ignore next */ +function copyAugment (target, src, keys) { + for (var i = 0, l = keys.length; i < l; i++) { + var key = keys[i]; + def(target, key, src[key]); + } +} + +/** + * Attempt to create an observer instance for a value, + * returns the new observer if successfully observed, + * or the existing observer if the value already has one. + */ +function observe (value, asRootData) { + if (!isObject(value) || value instanceof VNode) { + return + } + var ob; + if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) { + ob = value.__ob__; + } else if ( + shouldObserve && + !isServerRendering() && + (Array.isArray(value) || isPlainObject(value)) && + Object.isExtensible(value) && + !value._isVue + ) { + ob = new Observer(value); + } + if (asRootData && ob) { + ob.vmCount++; + } + return ob +} + +/** + * Define a reactive property on an Object. + */ +function defineReactive ( + obj, + key, + val, + customSetter, + shallow +) { + var dep = new Dep(); + + var property = Object.getOwnPropertyDescriptor(obj, key); + if (property && property.configurable === false) { + return + } + + // cater for pre-defined getter/setters + var getter = property && property.get; + if (!getter && arguments.length === 2) { + val = obj[key]; + } + var setter = property && property.set; + + var childOb = !shallow && observe(val); + Object.defineProperty(obj, key, { + enumerable: true, + configurable: true, + get: function reactiveGetter () { + var value = getter ? getter.call(obj) : val; + if (Dep.target) { + dep.depend(); + if (childOb) { + childOb.dep.depend(); + if (Array.isArray(value)) { + dependArray(value); + } + } + } + return value + }, + set: function reactiveSetter (newVal) { + var value = getter ? getter.call(obj) : val; + /* eslint-disable no-self-compare */ + if (newVal === value || (newVal !== newVal && value !== value)) { + return + } + /* eslint-enable no-self-compare */ + if ("development" !== 'production' && customSetter) { + customSetter(); + } + if (setter) { + setter.call(obj, newVal); + } else { + val = newVal; + } + childOb = !shallow && observe(newVal); + dep.notify(); + } + }); +} + +/** + * Set a property on an object. Adds the new property and + * triggers change notification if the property doesn't + * already exist. + */ +function set (target, key, val) { + if ("development" !== 'production' && + (isUndef(target) || isPrimitive(target)) + ) { + warn(("Cannot set reactive property on undefined, null, or primitive value: " + ((target)))); + } + if (Array.isArray(target) && isValidArrayIndex(key)) { + target.length = Math.max(target.length, key); + target.splice(key, 1, val); + return val + } + if (key in target && !(key in Object.prototype)) { + target[key] = val; + return val + } + var ob = (target).__ob__; + if (target._isVue || (ob && ob.vmCount)) { + "development" !== 'production' && warn( + 'Avoid adding reactive properties to a Vue instance or its root $data ' + + 'at runtime - declare it upfront in the data option.' + ); + return val + } + if (!ob) { + target[key] = val; + return val + } + defineReactive(ob.value, key, val); + ob.dep.notify(); + return val +} + +/** + * Delete a property and trigger change if necessary. + */ +function del (target, key) { + if ("development" !== 'production' && + (isUndef(target) || isPrimitive(target)) + ) { + warn(("Cannot delete reactive property on undefined, null, or primitive value: " + ((target)))); + } + if (Array.isArray(target) && isValidArrayIndex(key)) { + target.splice(key, 1); + return + } + var ob = (target).__ob__; + if (target._isVue || (ob && ob.vmCount)) { + "development" !== 'production' && warn( + 'Avoid deleting properties on a Vue instance or its root $data ' + + '- just set it to null.' + ); + return + } + if (!hasOwn(target, key)) { + return + } + delete target[key]; + if (!ob) { + return + } + ob.dep.notify(); +} + +/** + * Collect dependencies on array elements when the array is touched, since + * we cannot intercept array element access like property getters. + */ +function dependArray (value) { + for (var e = (void 0), i = 0, l = value.length; i < l; i++) { + e = value[i]; + e && e.__ob__ && e.__ob__.dep.depend(); + if (Array.isArray(e)) { + dependArray(e); + } + } +} + +/* */ + +/** + * Option overwriting strategies are functions that handle + * how to merge a parent option value and a child option + * value into the final value. + */ +var strats = config.optionMergeStrategies; + +/** + * Options with restrictions + */ +{ + strats.el = strats.propsData = function (parent, child, vm, key) { + if (!vm) { + warn( + "option \"" + key + "\" can only be used during instance " + + 'creation with the `new` keyword.' + ); + } + return defaultStrat(parent, child) + }; +} + +/** + * Helper that recursively merges two data objects together. + */ +function mergeData (to, from) { + if (!from) { return to } + var key, toVal, fromVal; + var keys = Object.keys(from); + for (var i = 0; i < keys.length; i++) { + key = keys[i]; + toVal = to[key]; + fromVal = from[key]; + if (!hasOwn(to, key)) { + set(to, key, fromVal); + } else if (isPlainObject(toVal) && isPlainObject(fromVal)) { + mergeData(toVal, fromVal); + } + } + return to +} + +/** + * Data + */ +function mergeDataOrFn ( + parentVal, + childVal, + vm +) { + if (!vm) { + // in a Vue.extend merge, both should be functions + if (!childVal) { + return parentVal + } + if (!parentVal) { + return childVal + } + // when parentVal & childVal are both present, + // we need to return a function that returns the + // merged result of both functions... no need to + // check if parentVal is a function here because + // it has to be a function to pass previous merges. + return function mergedDataFn () { + return mergeData( + typeof childVal === 'function' ? childVal.call(this, this) : childVal, + typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal + ) + } + } else { + return function mergedInstanceDataFn () { + // instance merge + var instanceData = typeof childVal === 'function' + ? childVal.call(vm, vm) + : childVal; + var defaultData = typeof parentVal === 'function' + ? parentVal.call(vm, vm) + : parentVal; + if (instanceData) { + return mergeData(instanceData, defaultData) + } else { + return defaultData + } + } + } +} + +strats.data = function ( + parentVal, + childVal, + vm +) { + if (!vm) { + if (childVal && typeof childVal !== 'function') { + "development" !== 'production' && warn( + 'The "data" option should be a function ' + + 'that returns a per-instance value in component ' + + 'definitions.', + vm + ); + + return parentVal + } + return mergeDataOrFn(parentVal, childVal) + } + + return mergeDataOrFn(parentVal, childVal, vm) +}; + +/** + * Hooks and props are merged as arrays. + */ +function mergeHook ( + parentVal, + childVal +) { + return childVal + ? parentVal + ? parentVal.concat(childVal) + : Array.isArray(childVal) + ? childVal + : [childVal] + : parentVal +} + +LIFECYCLE_HOOKS.forEach(function (hook) { + strats[hook] = mergeHook; +}); + +/** + * Assets + * + * When a vm is present (instance creation), we need to do + * a three-way merge between constructor options, instance + * options and parent options. + */ +function mergeAssets ( + parentVal, + childVal, + vm, + key +) { + var res = Object.create(parentVal || null); + if (childVal) { + "development" !== 'production' && assertObjectType(key, childVal, vm); + return extend(res, childVal) + } else { + return res + } +} + +ASSET_TYPES.forEach(function (type) { + strats[type + 's'] = mergeAssets; +}); + +/** + * Watchers. + * + * Watchers hashes should not overwrite one + * another, so we merge them as arrays. + */ +strats.watch = function ( + parentVal, + childVal, + vm, + key +) { + // work around Firefox's Object.prototype.watch... + if (parentVal === nativeWatch) { parentVal = undefined; } + if (childVal === nativeWatch) { childVal = undefined; } + /* istanbul ignore if */ + if (!childVal) { return Object.create(parentVal || null) } + { + assertObjectType(key, childVal, vm); + } + if (!parentVal) { return childVal } + var ret = {}; + extend(ret, parentVal); + for (var key$1 in childVal) { + var parent = ret[key$1]; + var child = childVal[key$1]; + if (parent && !Array.isArray(parent)) { + parent = [parent]; + } + ret[key$1] = parent + ? parent.concat(child) + : Array.isArray(child) ? child : [child]; + } + return ret +}; + +/** + * Other object hashes. + */ +strats.props = +strats.methods = +strats.inject = +strats.computed = function ( + parentVal, + childVal, + vm, + key +) { + if (childVal && "development" !== 'production') { + assertObjectType(key, childVal, vm); + } + if (!parentVal) { return childVal } + var ret = Object.create(null); + extend(ret, parentVal); + if (childVal) { extend(ret, childVal); } + return ret +}; +strats.provide = mergeDataOrFn; + +/** + * Default strategy. + */ +var defaultStrat = function (parentVal, childVal) { + return childVal === undefined + ? parentVal + : childVal +}; + +/** + * Validate component names + */ +function checkComponents (options) { + for (var key in options.components) { + validateComponentName(key); + } +} + +function validateComponentName (name) { + if (!/^[a-zA-Z][\w-]*$/.test(name)) { + warn( + 'Invalid component name: "' + name + '". Component names ' + + 'can only contain alphanumeric characters and the hyphen, ' + + 'and must start with a letter.' + ); + } + if (isBuiltInTag(name) || config.isReservedTag(name)) { + warn( + 'Do not use built-in or reserved HTML elements as component ' + + 'id: ' + name + ); + } +} + +/** + * Ensure all props option syntax are normalized into the + * Object-based format. + */ +function normalizeProps (options, vm) { + var props = options.props; + if (!props) { return } + var res = {}; + var i, val, name; + if (Array.isArray(props)) { + i = props.length; + while (i--) { + val = props[i]; + if (typeof val === 'string') { + name = camelize(val); + res[name] = { type: null }; + } else { + warn('props must be strings when using array syntax.'); + } + } + } else if (isPlainObject(props)) { + for (var key in props) { + val = props[key]; + name = camelize(key); + res[name] = isPlainObject(val) + ? val + : { type: val }; + } + } else { + warn( + "Invalid value for option \"props\": expected an Array or an Object, " + + "but got " + (toRawType(props)) + ".", + vm + ); + } + options.props = res; +} + +/** + * Normalize all injections into Object-based format + */ +function normalizeInject (options, vm) { + var inject = options.inject; + if (!inject) { return } + var normalized = options.inject = {}; + if (Array.isArray(inject)) { + for (var i = 0; i < inject.length; i++) { + normalized[inject[i]] = { from: inject[i] }; + } + } else if (isPlainObject(inject)) { + for (var key in inject) { + var val = inject[key]; + normalized[key] = isPlainObject(val) + ? extend({ from: key }, val) + : { from: val }; + } + } else { + warn( + "Invalid value for option \"inject\": expected an Array or an Object, " + + "but got " + (toRawType(inject)) + ".", + vm + ); + } +} + +/** + * Normalize raw function directives into object format. + */ +function normalizeDirectives (options) { + var dirs = options.directives; + if (dirs) { + for (var key in dirs) { + var def = dirs[key]; + if (typeof def === 'function') { + dirs[key] = { bind: def, update: def }; + } + } + } +} + +function assertObjectType (name, value, vm) { + if (!isPlainObject(value)) { + warn( + "Invalid value for option \"" + name + "\": expected an Object, " + + "but got " + (toRawType(value)) + ".", + vm + ); + } +} + +/** + * Merge two option objects into a new one. + * Core utility used in both instantiation and inheritance. + */ +function mergeOptions ( + parent, + child, + vm +) { + { + checkComponents(child); + } + + if (typeof child === 'function') { + child = child.options; + } + + normalizeProps(child, vm); + normalizeInject(child, vm); + normalizeDirectives(child); + var extendsFrom = child.extends; + if (extendsFrom) { + parent = mergeOptions(parent, extendsFrom, vm); + } + if (child.mixins) { + for (var i = 0, l = child.mixins.length; i < l; i++) { + parent = mergeOptions(parent, child.mixins[i], vm); + } + } + var options = {}; + var key; + for (key in parent) { + mergeField(key); + } + for (key in child) { + if (!hasOwn(parent, key)) { + mergeField(key); + } + } + function mergeField (key) { + var strat = strats[key] || defaultStrat; + options[key] = strat(parent[key], child[key], vm, key); + } + return options +} + +/** + * Resolve an asset. + * This function is used because child instances need access + * to assets defined in its ancestor chain. + */ +function resolveAsset ( + options, + type, + id, + warnMissing +) { + /* istanbul ignore if */ + if (typeof id !== 'string') { + return + } + var assets = options[type]; + // check local registration variations first + if (hasOwn(assets, id)) { return assets[id] } + var camelizedId = camelize(id); + if (hasOwn(assets, camelizedId)) { return assets[camelizedId] } + var PascalCaseId = capitalize(camelizedId); + if (hasOwn(assets, PascalCaseId)) { return assets[PascalCaseId] } + // fallback to prototype chain + var res = assets[id] || assets[camelizedId] || assets[PascalCaseId]; + if ("development" !== 'production' && warnMissing && !res) { + warn( + 'Failed to resolve ' + type.slice(0, -1) + ': ' + id, + options + ); + } + return res +} + +/* */ + +function validateProp ( + key, + propOptions, + propsData, + vm +) { + var prop = propOptions[key]; + var absent = !hasOwn(propsData, key); + var value = propsData[key]; + // boolean casting + var booleanIndex = getTypeIndex(Boolean, prop.type); + if (booleanIndex > -1) { + if (absent && !hasOwn(prop, 'default')) { + value = false; + } else if (value === '' || value === hyphenate(key)) { + // only cast empty string / same name to boolean if + // boolean has higher priority + var stringIndex = getTypeIndex(String, prop.type); + if (stringIndex < 0 || booleanIndex < stringIndex) { + value = true; + } + } + } + // check default value + if (value === undefined) { + value = getPropDefaultValue(vm, prop, key); + // since the default value is a fresh copy, + // make sure to observe it. + var prevShouldObserve = shouldObserve; + toggleObserving(true); + observe(value); + toggleObserving(prevShouldObserve); + } + { + assertProp(prop, key, value, vm, absent); + } + return value +} + +/** + * Get the default value of a prop. + */ +function getPropDefaultValue (vm, prop, key) { + // no default, return undefined + if (!hasOwn(prop, 'default')) { + return undefined + } + var def = prop.default; + // warn against non-factory defaults for Object & Array + if ("development" !== 'production' && isObject(def)) { + warn( + 'Invalid default value for prop "' + key + '": ' + + 'Props with type Object/Array must use a factory function ' + + 'to return the default value.', + vm + ); + } + // the raw prop value was also undefined from previous render, + // return previous default value to avoid unnecessary watcher trigger + if (vm && vm.$options.propsData && + vm.$options.propsData[key] === undefined && + vm._props[key] !== undefined + ) { + return vm._props[key] + } + // call factory function for non-Function types + // a value is Function if its prototype is function even across different execution context + return typeof def === 'function' && getType(prop.type) !== 'Function' + ? def.call(vm) + : def +} + +/** + * Assert whether a prop is valid. + */ +function assertProp ( + prop, + name, + value, + vm, + absent +) { + if (prop.required && absent) { + warn( + 'Missing required prop: "' + name + '"', + vm + ); + return + } + if (value == null && !prop.required) { + return + } + var type = prop.type; + var valid = !type || type === true; + var expectedTypes = []; + if (type) { + if (!Array.isArray(type)) { + type = [type]; + } + for (var i = 0; i < type.length && !valid; i++) { + var assertedType = assertType(value, type[i]); + expectedTypes.push(assertedType.expectedType || ''); + valid = assertedType.valid; + } + } + if (!valid) { + warn( + "Invalid prop: type check failed for prop \"" + name + "\"." + + " Expected " + (expectedTypes.map(capitalize).join(', ')) + + ", got " + (toRawType(value)) + ".", + vm + ); + return + } + var validator = prop.validator; + if (validator) { + if (!validator(value)) { + warn( + 'Invalid prop: custom validator check failed for prop "' + name + '".', + vm + ); + } + } +} + +var simpleCheckRE = /^(String|Number|Boolean|Function|Symbol)$/; + +function assertType (value, type) { + var valid; + var expectedType = getType(type); + if (simpleCheckRE.test(expectedType)) { + var t = typeof value; + valid = t === expectedType.toLowerCase(); + // for primitive wrapper objects + if (!valid && t === 'object') { + valid = value instanceof type; + } + } else if (expectedType === 'Object') { + valid = isPlainObject(value); + } else if (expectedType === 'Array') { + valid = Array.isArray(value); + } else { + valid = value instanceof type; + } + return { + valid: valid, + expectedType: expectedType + } +} + +/** + * Use function string name to check built-in types, + * because a simple equality check will fail when running + * across different vms / iframes. + */ +function getType (fn) { + var match = fn && fn.toString().match(/^\s*function (\w+)/); + return match ? match[1] : '' +} + +function isSameType (a, b) { + return getType(a) === getType(b) +} + +function getTypeIndex (type, expectedTypes) { + if (!Array.isArray(expectedTypes)) { + return isSameType(expectedTypes, type) ? 0 : -1 + } + for (var i = 0, len = expectedTypes.length; i < len; i++) { + if (isSameType(expectedTypes[i], type)) { + return i + } + } + return -1 +} + +/* */ + +function handleError (err, vm, info) { + if (vm) { + var cur = vm; + while ((cur = cur.$parent)) { + var hooks = cur.$options.errorCaptured; + if (hooks) { + for (var i = 0; i < hooks.length; i++) { + try { + var capture = hooks[i].call(cur, err, vm, info) === false; + if (capture) { return } + } catch (e) { + globalHandleError(e, cur, 'errorCaptured hook'); + } + } + } + } + } + globalHandleError(err, vm, info); +} + +function globalHandleError (err, vm, info) { + if (config.errorHandler) { + try { + return config.errorHandler.call(null, err, vm, info) + } catch (e) { + logError(e, null, 'config.errorHandler'); + } + } + logError(err, vm, info); +} + +function logError (err, vm, info) { + { + warn(("Error in " + info + ": \"" + (err.toString()) + "\""), vm); + } + /* istanbul ignore else */ + if ((inBrowser || inWeex) && typeof console !== 'undefined') { + console.error(err); + } else { + throw err + } +} + +/* */ +/* globals MessageChannel */ + +var callbacks = []; +var pending = false; + +function flushCallbacks () { + pending = false; + var copies = callbacks.slice(0); + callbacks.length = 0; + for (var i = 0; i < copies.length; i++) { + copies[i](); + } +} + +// Here we have async deferring wrappers using both microtasks and (macro) tasks. +// In < 2.4 we used microtasks everywhere, but there are some scenarios where +// microtasks have too high a priority and fire in between supposedly +// sequential events (e.g. #4521, #6690) or even between bubbling of the same +// event (#6566). However, using (macro) tasks everywhere also has subtle problems +// when state is changed right before repaint (e.g. #6813, out-in transitions). +// Here we use microtask by default, but expose a way to force (macro) task when +// needed (e.g. in event handlers attached by v-on). +var microTimerFunc; +var macroTimerFunc; +var useMacroTask = false; + +// Determine (macro) task defer implementation. +// Technically setImmediate should be the ideal choice, but it's only available +// in IE. The only polyfill that consistently queues the callback after all DOM +// events triggered in the same loop is by using MessageChannel. +/* istanbul ignore if */ +if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { + macroTimerFunc = function () { + setImmediate(flushCallbacks); + }; +} else if (typeof MessageChannel !== 'undefined' && ( + isNative(MessageChannel) || + // PhantomJS + MessageChannel.toString() === '[object MessageChannelConstructor]' +)) { + var channel = new MessageChannel(); + var port = channel.port2; + channel.port1.onmessage = flushCallbacks; + macroTimerFunc = function () { + port.postMessage(1); + }; +} else { + /* istanbul ignore next */ + macroTimerFunc = function () { + setTimeout(flushCallbacks, 0); + }; +} + +// Determine microtask defer implementation. +/* istanbul ignore next, $flow-disable-line */ +if (typeof Promise !== 'undefined' && isNative(Promise)) { + var p = Promise.resolve(); + microTimerFunc = function () { + p.then(flushCallbacks); + // in problematic UIWebViews, Promise.then doesn't completely break, but + // it can get stuck in a weird state where callbacks are pushed into the + // microtask queue but the queue isn't being flushed, until the browser + // needs to do some other work, e.g. handle a timer. Therefore we can + // "force" the microtask queue to be flushed by adding an empty timer. + if (isIOS) { setTimeout(noop); } + }; +} else { + // fallback to macro + microTimerFunc = macroTimerFunc; +} + +/** + * Wrap a function so that if any code inside triggers state change, + * the changes are queued using a (macro) task instead of a microtask. + */ +function withMacroTask (fn) { + return fn._withTask || (fn._withTask = function () { + useMacroTask = true; + var res = fn.apply(null, arguments); + useMacroTask = false; + return res + }) +} + +function nextTick (cb, ctx) { + var _resolve; + callbacks.push(function () { + if (cb) { + try { + cb.call(ctx); + } catch (e) { + handleError(e, ctx, 'nextTick'); + } + } else if (_resolve) { + _resolve(ctx); + } + }); + if (!pending) { + pending = true; + if (useMacroTask) { + macroTimerFunc(); + } else { + microTimerFunc(); + } + } + // $flow-disable-line + if (!cb && typeof Promise !== 'undefined') { + return new Promise(function (resolve) { + _resolve = resolve; + }) + } +} + +/* */ + +var mark; +var measure; + +{ + var perf = inBrowser && window.performance; + /* istanbul ignore if */ + if ( + perf && + perf.mark && + perf.measure && + perf.clearMarks && + perf.clearMeasures + ) { + mark = function (tag) { return perf.mark(tag); }; + measure = function (name, startTag, endTag) { + perf.measure(name, startTag, endTag); + perf.clearMarks(startTag); + perf.clearMarks(endTag); + perf.clearMeasures(name); + }; + } +} + +/* not type checking this file because flow doesn't play well with Proxy */ + +var initProxy; + +{ + var allowedGlobals = makeMap( + 'Infinity,undefined,NaN,isFinite,isNaN,' + + 'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' + + 'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,' + + 'require' // for Webpack/Browserify + ); + + var warnNonPresent = function (target, key) { + warn( + "Property or method \"" + key + "\" is not defined on the instance but " + + 'referenced during render. Make sure that this property is reactive, ' + + 'either in the data option, or for class-based components, by ' + + 'initializing the property. ' + + 'See: https://vuejs.org/v2/guide/reactivity.html#Declaring-Reactive-Properties.', + target + ); + }; + + var hasProxy = + typeof Proxy !== 'undefined' && isNative(Proxy); + + if (hasProxy) { + var isBuiltInModifier = makeMap('stop,prevent,self,ctrl,shift,alt,meta,exact'); + config.keyCodes = new Proxy(config.keyCodes, { + set: function set (target, key, value) { + if (isBuiltInModifier(key)) { + warn(("Avoid overwriting built-in modifier in config.keyCodes: ." + key)); + return false + } else { + target[key] = value; + return true + } + } + }); + } + + var hasHandler = { + has: function has (target, key) { + var has = key in target; + var isAllowed = allowedGlobals(key) || key.charAt(0) === '_'; + if (!has && !isAllowed) { + warnNonPresent(target, key); + } + return has || !isAllowed + } + }; + + var getHandler = { + get: function get (target, key) { + if (typeof key === 'string' && !(key in target)) { + warnNonPresent(target, key); + } + return target[key] + } + }; + + initProxy = function initProxy (vm) { + if (hasProxy) { + // determine which proxy handler to use + var options = vm.$options; + var handlers = options.render && options.render._withStripped + ? getHandler + : hasHandler; + vm._renderProxy = new Proxy(vm, handlers); + } else { + vm._renderProxy = vm; + } + }; +} + +/* */ + +var seenObjects = new _Set(); + +/** + * Recursively traverse an object to evoke all converted + * getters, so that every nested property inside the object + * is collected as a "deep" dependency. + */ +function traverse (val) { + _traverse(val, seenObjects); + seenObjects.clear(); +} + +function _traverse (val, seen) { + var i, keys; + var isA = Array.isArray(val); + if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) { + return + } + if (val.__ob__) { + var depId = val.__ob__.dep.id; + if (seen.has(depId)) { + return + } + seen.add(depId); + } + if (isA) { + i = val.length; + while (i--) { _traverse(val[i], seen); } + } else { + keys = Object.keys(val); + i = keys.length; + while (i--) { _traverse(val[keys[i]], seen); } + } +} + +/* */ + +var normalizeEvent = cached(function (name) { + var passive = name.charAt(0) === '&'; + name = passive ? name.slice(1) : name; + var once$$1 = name.charAt(0) === '~'; // Prefixed last, checked first + name = once$$1 ? name.slice(1) : name; + var capture = name.charAt(0) === '!'; + name = capture ? name.slice(1) : name; + return { + name: name, + once: once$$1, + capture: capture, + passive: passive + } +}); + +function createFnInvoker (fns) { + function invoker () { + var arguments$1 = arguments; + + var fns = invoker.fns; + if (Array.isArray(fns)) { + var cloned = fns.slice(); + for (var i = 0; i < cloned.length; i++) { + cloned[i].apply(null, arguments$1); + } + } else { + // return handler return value for single handlers + return fns.apply(null, arguments) + } + } + invoker.fns = fns; + return invoker +} + +function updateListeners ( + on, + oldOn, + add, + remove$$1, + vm +) { + var name, def, cur, old, event; + for (name in on) { + def = cur = on[name]; + old = oldOn[name]; + event = normalizeEvent(name); + /* istanbul ignore if */ + if (isUndef(cur)) { + "development" !== 'production' && warn( + "Invalid handler for event \"" + (event.name) + "\": got " + String(cur), + vm + ); + } else if (isUndef(old)) { + if (isUndef(cur.fns)) { + cur = on[name] = createFnInvoker(cur); + } + add(event.name, cur, event.once, event.capture, event.passive, event.params); + } else if (cur !== old) { + old.fns = cur; + on[name] = old; + } + } + for (name in oldOn) { + if (isUndef(on[name])) { + event = normalizeEvent(name); + remove$$1(event.name, oldOn[name], event.capture); + } + } +} + +/* */ + +function mergeVNodeHook (def, hookKey, hook) { + if (def instanceof VNode) { + def = def.data.hook || (def.data.hook = {}); + } + var invoker; + var oldHook = def[hookKey]; + + function wrappedHook () { + hook.apply(this, arguments); + // important: remove merged hook to ensure it's called only once + // and prevent memory leak + remove(invoker.fns, wrappedHook); + } + + if (isUndef(oldHook)) { + // no existing hook + invoker = createFnInvoker([wrappedHook]); + } else { + /* istanbul ignore if */ + if (isDef(oldHook.fns) && isTrue(oldHook.merged)) { + // already a merged invoker + invoker = oldHook; + invoker.fns.push(wrappedHook); + } else { + // existing plain hook + invoker = createFnInvoker([oldHook, wrappedHook]); + } + } + + invoker.merged = true; + def[hookKey] = invoker; +} + +/* */ + +function extractPropsFromVNodeData ( + data, + Ctor, + tag +) { + // we are only extracting raw values here. + // validation and default values are handled in the child + // component itself. + var propOptions = Ctor.options.props; + if (isUndef(propOptions)) { + return + } + var res = {}; + var attrs = data.attrs; + var props = data.props; + if (isDef(attrs) || isDef(props)) { + for (var key in propOptions) { + var altKey = hyphenate(key); + { + var keyInLowerCase = key.toLowerCase(); + if ( + key !== keyInLowerCase && + attrs && hasOwn(attrs, keyInLowerCase) + ) { + tip( + "Prop \"" + keyInLowerCase + "\" is passed to component " + + (formatComponentName(tag || Ctor)) + ", but the declared prop name is" + + " \"" + key + "\". " + + "Note that HTML attributes are case-insensitive and camelCased " + + "props need to use their kebab-case equivalents when using in-DOM " + + "templates. You should probably use \"" + altKey + "\" instead of \"" + key + "\"." + ); + } + } + checkProp(res, props, key, altKey, true) || + checkProp(res, attrs, key, altKey, false); + } + } + return res +} + +function checkProp ( + res, + hash, + key, + altKey, + preserve +) { + if (isDef(hash)) { + if (hasOwn(hash, key)) { + res[key] = hash[key]; + if (!preserve) { + delete hash[key]; + } + return true + } else if (hasOwn(hash, altKey)) { + res[key] = hash[altKey]; + if (!preserve) { + delete hash[altKey]; + } + return true + } + } + return false +} + +/* */ + +// The template compiler attempts to minimize the need for normalization by +// statically analyzing the template at compile time. +// +// For plain HTML markup, normalization can be completely skipped because the +// generated render function is guaranteed to return Array. There are +// two cases where extra normalization is needed: + +// 1. When the children contains components - because a functional component +// may return an Array instead of a single root. In this case, just a simple +// normalization is needed - if any child is an Array, we flatten the whole +// thing with Array.prototype.concat. It is guaranteed to be only 1-level deep +// because functional components already normalize their own children. +function simpleNormalizeChildren (children) { + for (var i = 0; i < children.length; i++) { + if (Array.isArray(children[i])) { + return Array.prototype.concat.apply([], children) + } + } + return children +} + +// 2. When the children contains constructs that always generated nested Arrays, +// e.g.