diff --git a/bin/telebit-remote.js b/bin/telebit-remote.js index 75a5993..bbff336 100755 --- a/bin/telebit-remote.js +++ b/bin/telebit-remote.js @@ -25,6 +25,7 @@ var camelCopy = recase.camelCopy.bind(recase); //var snakeCopy = recase.snakeCopy.bind(recase); var urequest = require('@coolaj86/urequest'); +var urequestAsync = require('util').promisify(urequest); var common = require('../lib/cli-common.js'); var defaultConfPath = path.join(os.homedir(), '.config/telebit'); @@ -336,8 +337,6 @@ var RC; function parseConfig(err, text) { function handleConfig(err, config) { - if (err) { throw err; } - state.config = config; var verstrd = [ pkg.name + ' daemon v' + state.config.version ]; if (state.config.version && state.config.version !== pkg.version) { @@ -682,7 +681,52 @@ function parseConfig(err, text) { }); } - RC.request({ service: 'config', method: 'GET' }, handleConfig); + var bootState = {}; + function bootstrap() { + // 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') }).then(function (resp) { + var nonce = resp.headers['replay-nonce']; + var newAccountUrl = RC.resolve('/new-acct'); + return keypairs.signJws({ + jwk: state.key + , protected: { + // alg will be filled out automatically + jwk: state.pub + , 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: [] // I don't think we have email yet... + //, externalAccountBinding: null + }) + }).then(function (jws) { + return urequestAsync({ + url: newAccountUrl + , json: jws + , headers: { "Content-Type": 'application/jose+json' } + }).then(function (resp) { + console.log('resp.body:'); + console.log(resp.body); + if (!resp.body || 'valid' !== resp.body.status) { + throw new Error("did not successfully create or restore account"); + } + return RC.requestAsync({ service: 'config', method: 'GET' }).catch(function (err) { + console.error(err.stack); + process.exit(27); + }).then(handleConfig); + }); + }); + }).catch(RC.createErrorHandler(bootstrap, bootState, function (err) { + console.error(err); + process.exit(17); + })); + } + + bootstrap(); } var parsers = { diff --git a/bin/telebitd.js b/bin/telebitd.js index 3019339..646c1a8 100755 --- a/bin/telebitd.js +++ b/bin/telebitd.js @@ -16,6 +16,7 @@ var crypto = require('crypto'); var path = require('path'); var os = require('os'); var fs = require('fs'); +var fsp = fs.promises; var urequest = require('@coolaj86/urequest'); var urequestAsync = require('util').promisify(urequest); var common = require('../lib/cli-common.js'); @@ -110,8 +111,20 @@ 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) { @@ -395,7 +408,8 @@ controllers.newNonce = function (req, res) { //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.headers.set("Link", "Link: <" + indexUrl + ">;rel=\"index\""); + res.headers.set("Link", "<" + indexUrl + ">;rel=\"index\""); + res.headers.set("Cache-Control", "max-age=0, no-cache, no-store"); res.headers.set("Pragma", "no-cache"); //res.headers.set("Strict-Transport-Security", "max-age=604800"); res.headers.set("X-Frame-Options", "DENY"); @@ -418,11 +432,13 @@ controllers.newAccount = function (req, res) { // 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, @@ -435,46 +451,74 @@ controllers.newAccount = function (req, res) { return; } - // 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 + 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 - // check if this account already exists - DB.accounts.some(function (/*jwk*/) { - // calculate thumbprint from jwk - // find a key with matching jwk - }); - // TODO fail if onlyReturnExisting is not false - // req.body.onlyReturnExisting: false + // 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.statusCode = 500; - res.send({ - error: { message: "not implemented" }, - "id": 0, // 5937234, - "key": req.jws.header.jwk, // TODO trim to basics - /*{ - "kty": "EC", - "crv": "P-256", - "x": "G7kuV4JiqZs-GztrzpsmUM7Raf9tDUELWt5O337sTqw", - "y": "n5SFz9z2i-ZF_zu5aoS9t9O8y_g2qfonXv3Cna2e39k" - },*/ - "contact": req.body.contact, // [ "mailto:john.doe@gmail.com" ], - // 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", - "status": "invalid" //"valid" + 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 = 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; + DB.accounts.push(account); + state.config.accounts = DB.accounts; + saveConfig(function () {}); + } + + 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 + */ }); - /* - 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 - */ }); }); }; @@ -550,7 +594,7 @@ 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 require('crypto') + return crypto .createVerify(alg) .update(jws.protected + '.' + jws.payload) .verify(pem, sig, 'base64'); @@ -915,14 +959,32 @@ function handleApi() { } // TODO turn strings into regexes to match beginnings + app.use('/.well-known/openid-configuration', function (req, res) { + res.headers.set("Access-Control-Allow-Headers", "Content-Type"); + res.headers.set("Access-Control-Allow-Origin", "*"); + res.headers.set("Access-Control-Expose-Headers", "Link, Replay-Nonce, Location"); + res.headers.set("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.headers.set("Access-Control-Allow-Headers", "Content-Type"); res.headers.set("Access-Control-Allow-Origin", "*"); res.headers.set("Access-Control-Expose-Headers", "Link, Replay-Nonce, Location"); res.headers.set("Access-Control-Max-Age", "86400"); + if ('OPTIONS' === req.method) { res.end(); return; } next(); }); + app.use('/acme/directory', function (req, res) { + res.send({ + 'new-nonce': '/acme/new-nonce' + , 'new-account': '/acme/new-acct' + }); + }); app.use('/acme/new-nonce', controllers.newNonce); app.use('/acme/new-acct', controllers.newAccount); app.use(/\b(relay)\b/, controllers.relay); @@ -1098,6 +1160,7 @@ function parseConfig(err, text) { } state.config = camelCopy(state.config || {}) || {}; + DB.accounts = state.config.accounts || []; run(); diff --git a/lib/rc/index.js b/lib/rc/index.js index adc0000..9ffa841 100644 --- a/lib/rc/index.js +++ b/lib/rc/index.js @@ -72,6 +72,49 @@ module.exports.create = function (state) { } var RC = {}; + RC.resolve = function (pathstr) { + // TODO use real hostname and return reqOpts rather than string? + return 'http://localhost:' + RC.port({}).port.toString() + '/' + pathstr.replace(/^\//, ''); + }; + RC.port = function (reqOpts) { + var fs = require('fs'); + var portFile = path.join(path.dirname(state._ipc.path), 'telebit.port'); + if (fs.existsSync(portFile)) { + reqOpts.host = 'localhost'; + reqOpts.port = parseInt(fs.readFileSync(portFile, 'utf8').trim(), 10); + if (!state.ipc) { + state.ipc = {}; + } + state.ipc.type = 'port'; + state.ipc.path = path.dirname(state._ipc.path); + state.ipc.port = reqOpts.port; + } else { + reqOpts.socketPath = state._ipc.path; + } + return reqOpts; + }; + RC.createErrorhandler = function (replay, opts, cb) { + return 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) { + cb(err); + return; + } + require('../../usr/share/install-launcher.js').install({ env: process.env }, function (err) { + if (err) { cb(err); return; } + opts._taketwo = true; + setTimeout(function () { + replay(opts, cb); + }, 2500); + }); + return; + } + + cb(err); + }; + }; RC.request = function request(opts, fn) { if (!opts) { opts = {}; } var service = opts.service || 'config'; @@ -93,44 +136,12 @@ module.exports.create = function (state) { method: method , path: url }; - var fs = require('fs'); - var portFile = path.join(path.dirname(state._ipc.path), 'telebit.port'); - if (fs.existsSync(portFile)) { - reqOpts.host = 'localhost'; - reqOpts.port = parseInt(fs.readFileSync(portFile, 'utf8').trim(), 10); - if (!state.ipc) { - state.ipc = {}; - } - state.ipc.type = 'port'; - state.ipc.path = path.dirname(state._ipc.path); - state.ipc.port = reqOpts.port; - } else { - reqOpts.socketPath = state._ipc.path; - } + reqOpts = RC.port(reqOpts); var req = http.request(reqOpts, function (resp) { makeResponder(service, resp, fn); }); - 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) { - fn(err); - return; - } - require('../../usr/share/install-launcher.js').install({ env: process.env }, function (err) { - if (err) { fn(err); return; } - opts._taketwo = true; - setTimeout(function () { - RC.request(opts, fn); - }, 2500); - }); - return; - } - - fn(err); - }); + req.on('error', RC.createErrorHandler(RC.request, opts, fn)); // Simple GET if ('POST' !== method || !opts.data) { @@ -150,7 +161,8 @@ module.exports.create = function (state) { // alg will be filled out automatically jwk: state.pub , nonce: require('crypto').randomBytes(16).toString('hex') // TODO get from server - , url: 'https://' + reqOpts.host + reqOpts.path + // TODO make localhost exceptional + , url: RC.resolve(reqOpts.path) } , payload: JSON.stringify(opts.data) }).then(function (jws) { diff --git a/package-lock.json b/package-lock.json index 076c11c..40acb1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -435,9 +435,9 @@ } }, "keypairs": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/keypairs/-/keypairs-1.2.12.tgz", - "integrity": "sha512-zYjYdDvo7G4AIkkZVM3WEJBTRUIrFzYswYNqCxcCPHUsgbBBdewSHAH1CiaQ+VA6Yb7BLEPIv8gFrRz5wJrgsw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/keypairs/-/keypairs-1.2.14.tgz", + "integrity": "sha512-ZoZfZMygyB0QcjSlz7Rh6wT2CJasYEHBPETtmHZEfxuJd7bnsOG5AdtPZqHZBT+hoHvuWCp/4y8VmvTvH0Y9uA==", "requires": { "eckles": "^1.4.1", "rasha": "^1.2.4" diff --git a/package.json b/package.json index 354dd8c..d67ed47 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "greenlock": "^2.6.7", "js-yaml": "^3.11.0", "keyfetch": "^1.1.8", - "keypairs": "^1.2.12", + "keypairs": "^1.2.14", "mkdirp": "^0.5.1", "proxy-packer": "^2.0.2", "ps-list": "^5.0.0",