diff --git a/bin/telebit-remote.js b/bin/telebit-remote.js index 66b3c65..75a5993 100755 --- a/bin/telebit-remote.js +++ b/bin/telebit-remote.js @@ -768,7 +768,6 @@ state.keystoreSecure = !keystore.insecure; keystore.all().then(function (list) { var keyext = '.key.jwk.json'; var key; - var convert; // 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) { @@ -778,14 +777,6 @@ keystore.all().then(function (list) { return true; } }); - if (!key) { - list.some(function (el) { - if (el.password.kty) { - convert = el.password; - return true; - } - }); - } if (key) { state.key = key; @@ -796,7 +787,6 @@ keystore.all().then(function (list) { return keypairs.generate().then(function (pair) { var jwk = pair.private; - if (convert) { jwk = convert; } return keypairs.thumbprint({ jwk: jwk }).then(function (kid) { jwk.kid = kid; return keystore.set(kid + keyext, jwk).then(function () { diff --git a/bin/telebitd.js b/bin/telebitd.js index 190c955..d17a9ba 100755 --- a/bin/telebitd.js +++ b/bin/telebitd.js @@ -11,6 +11,7 @@ try { var pkg = require('../package.json'); +var crypto = require('crypto'); //var url = require('url'); var path = require('path'); var os = require('os'); @@ -29,6 +30,7 @@ var startTime = Date.now(); var connectTimes = []; var isConnected = false; var eggspress = require('../lib/eggspress.js'); +var keypairs = require('keypairs'); var TelebitRemote = require('../lib/daemon/index.js').TelebitRemote; @@ -370,6 +372,50 @@ controllers.relay = function (req, res) { res.end(JSON.stringify(resp)); }); }; +controllers._nonces = {}; +controllers._requireNonce = function (req, res, next) { + var nonce = req.jws && req.jws.protected && req.jws.protected.nonce; + var active = (Date.now() - controllers._nonces[nonce]) < (4 * 60 * 60 * 1000); + if (!active) { + // TODO proper headers and error message + res.end({ "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.headers.set("Replay-Nonce", nonce); + return nonce; +}; +controllers.newNonce = function (req, res) { + res.statusCode = 200; + res.headers.set("Cache-Control", "max-age=0, no-cache, no-store"); + // TODO + //res.headers.set("Date", "Sun, 10 Mar 2019 08:04:45 GMT"); + // is this the expiration of the nonce itself? methinks maybe so + //res.headers.set("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.headers.set("Link", "Link: <" + indexUrl + ">;rel=\"index\""); + res.headers.set("Pragma", "no-cache"); + //res.headers.set("Strict-Transport-Security", "max-age=604800"); + res.headers.set("X-Frame-Options", "DENY"); + + res.end(""); +}; +controllers.newAccount = function (req, res) { + controllers._requireNonce(req, res, function () { + res.statusCode = 500; + res.end("not implemented yet"); + }); +}; function jsonEggspress(req, res, next) { /* @@ -438,7 +484,7 @@ function jwtEggspress(req, res, next) { } function verifyJws(jwk, jws) { - return require('keypairs').export({ jwk: jwk }).then(function (pem) { + 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') @@ -799,6 +845,16 @@ function handleApi() { } // TODO turn strings into regexes to match beginnings + 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"); + next(); + }); + app.use('/acme/new-nonce', controllers.newNonce); + app.use('/acme/new-acct', controllers.newAccount); app.use(/\b(relay)\b/, controllers.relay); app.get(/\b(config)\b/, getConfigOnly); app.use(/\b(init|config)\b/, initOrConfig); @@ -1374,36 +1430,69 @@ state.net = state.net || { } }; +var jwks = []; var token; var tokenname = "access_token.jwt"; -// backwards-compatibility shim 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(); -} -var jwks = []; +} catch(e) { onKeystore(); } function onKeystore() { return keystore.all().then(function (list) { + var keyext = '.key.jwk.json'; + var pubext = '.pub.jwk.json'; + 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; } - // these are secret because just adding the - // willy-nilly to the fs can allow arbitrary tokens - if (/\.pub\.jwk\.json$/.test(el.account)) { + + // 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 jwks.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); + }); + }); }); - fs.readFile(confpath, 'utf8', parseConfig); }); } }()); @@ -1437,7 +1526,11 @@ function ecdsaAsn1SigToJwtSig(alg, b64sig) { , Buffer.from([0x02, s.byteLength]), s ]); - return buf.toString('base64') + return toUrlSafe(buf.toString('base64')); +} + +function toUrlSafe(b64) { + return b64 .replace(/-/g, '+') .replace(/_/g, '/') .replace(/=/g, '')