diff --git a/bin/telebit-remote.js b/bin/telebit-remote.js index 5a4aab1..66b3c65 100755 --- a/bin/telebit-remote.js +++ b/bin/telebit-remote.js @@ -763,11 +763,31 @@ var parsers = { }; var keystore = require('../lib/keystore.js').create(state); -var keyname = 'telebit-remote'; state.keystore = keystore; state.keystoreSecure = !keystore.insecure; -keystore.get(keyname).then(function (key) { - if (key && key.kty && key.kid) { +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) { + if (keyext === el.account.slice(-keyext.length) + && el.password.kty && el.password.kid) { + key = el.password; + return true; + } + }); + if (!key) { + list.some(function (el) { + if (el.password.kty) { + convert = el.password; + return true; + } + }); + } + + if (key) { state.key = key; state.pub = keypairs.neuter({ jwk: key }); fs.readFile(confpath, 'utf8', parseConfig); @@ -776,9 +796,10 @@ keystore.get(keyname).then(function (key) { return keypairs.generate().then(function (pair) { var jwk = pair.private; - return keypairs.thumbprint({ jwk: pair.public }).then(function (kid) { + if (convert) { jwk = convert; } + return keypairs.thumbprint({ jwk: jwk }).then(function (kid) { jwk.kid = kid; - return keystore.set(keyname, jwk).then(function () { + 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; diff --git a/bin/telebitd.js b/bin/telebitd.js index 9f5e622..baae480 100755 --- a/bin/telebitd.js +++ b/bin/telebitd.js @@ -73,14 +73,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; @@ -442,14 +439,12 @@ function jwtEggspress(req, res, next) { function verifyJws(jwk, jws) { return require('keypairs').export({ jwk: jwk }).then(function (pem) { - var alg = 'RSA-SHA' + jws.header.alg.replace(/[^\d]+/i, ''); - // XXX - // TODO check for public key in keytar - // XXX + var alg = 'SHA' + jws.header.alg.replace(/[^\d]+/i, ''); + var sig = ecdsaAsn1SigToJwtSig(jws.header.alg, jws.signature); return require('crypto') .createVerify(alg) .update(jws.protected + '.' + jws.payload) - .verify(pem, jws.signature, 'base64'); + .verify(pem, sig, 'base64'); }); } @@ -465,16 +460,31 @@ function jwsEggspress(req, res, next) { if ('{'.charCodeAt(0) === req.body[0] || '['.charCodeAt(0) === req.body[0]) { req.body = JSON.parse(req.body); } - if (req.jws.header.jwk) { - verifyJws(req.jws.header.jwk, req.jws).then(function (verified) { - req.jws.selfVerified = verified; - next(); - }); - return; + + var vjwk; + jwks.some(function (jwk) { + if (jwk.kid === req.jws.header.kid) { + vjwk = jwk; + } + }); + if ((0 === jwks.length && req.jws.header.jwk)) { + vjwk = req.jws.header.jwk; + if (!vjwk.kid) { throw Error("Impossible: no key id"); } } - // TODO verify if possible - next(); + return verifyJws(vjwk, req.jws).then(function (verified) { + if (true !== verified) { + return; + } + req.jws.verified = verified; + + if (0 !== jwks.length) { + return; + } + return keystore.set(vjwk.kid + '.pub.jwk.json', vjwk); + }).then(function () { + next(); + }); } function handleApi() { @@ -883,7 +893,9 @@ function serveControlsHelper() { // nada } setTimeout(function () { - console.log("trying again"); + console.log("Could not start control server (%s), trying again...", err.code); + console.log(portFile); + console.log(serverOpts); serveControlsHelper(); }, 1000); return; @@ -1313,15 +1325,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); + }); } }; @@ -1358,6 +1374,72 @@ state.net = state.net || { } }; -fs.readFile(confpath, 'utf8', parseConfig); - +var token; +var tokenname = "access_token.jwt"; +// backwards-compatibility shim +try { + 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 = []; +function onKeystore() { + return keystore.all().then(function (list) { + list.forEach(function (el) { + 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)) { + // pre-parsed + jwks.push(el.password); + return; + } + }); + 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 buf.toString('base64') + .replace(/-/g, '+') + .replace(/_/g, '/') + .replace(/=/g, '') + ; +} diff --git a/lib/eggspress.js b/lib/eggspress.js index 1f76b63..cc10f88 100644 --- a/lib/eggspress.js +++ b/lib/eggspress.js @@ -34,14 +34,21 @@ module.exports = function eggspress() { return; } - try { - //console.log("[eggspress] matched pattern", todo[0], req.url); - todo[1](req, res, next); - } catch(e) { + function fail(e) { console.error("[eggspress] error", todo[2], todo[0], req.url); console.error(e); // TODO make a nice error message res.end(e.message); + } + + try { + console.log("[eggspress] matched pattern", todo[0], req.url); + var p = todo[1](req, res, next); + if (p && p.catch) { + p.catch(fail); + } + } catch(e) { + fail(e); return; } } diff --git a/lib/keystore-fallback.js b/lib/keystore-fallback.js index 782a3c4..f0a91e2 100644 --- a/lib/keystore-fallback.js +++ b/lib/keystore-fallback.js @@ -1,12 +1,14 @@ 'use strict'; +/*global Promise*/ var fs = require('fs').promises; var path = require('path'); module.exports.create = function (opts) { + var keyext = '.key'; return { getPassword: function (service, name) { - var f = path.join(opts.configDir, name + '.key'); + var f = path.join(opts.configDir, name + keyext); return fs.readFile(f, 'utf8').catch(function (err) { if ('ENOEXIST' === err.code) { return; @@ -14,13 +16,22 @@ module.exports.create = function (opts) { }); } , setPassword: function (service, name, key) { - var f = path.join(opts.configDir, name + '.key'); + var f = path.join(opts.configDir, name + keyext); return fs.writeFile(f, key, 'utf8'); } , deletePassword: function (service, name) { - var f = path.join(opts.configDir, name + '.key'); + var f = path.join(opts.configDir, name + keyext); return fs.unlink(f); } + , findCredentials: function (/*service*/) { + return fs.readDir(opts.configDir).then(function (nodes) { + return Promise.all(nodes.filter(function (node) { + return keyext === node.slice(-4); + }).map(function (node) { + return fs.readFile(path.join(opts.configDir, node + keyext)); + })); + }); + } , insecure: true }; }; diff --git a/lib/keystore.js b/lib/keystore.js index 69c6bbd..876e151 100644 --- a/lib/keystore.js +++ b/lib/keystore.js @@ -1,10 +1,11 @@ 'use strict'; module.exports.create = function (opts) { - var service = "Telebit"; + var service = opts.name || "Telebit"; var keytar; try { keytar = require('keytar'); + // TODO test that long "passwords" (JWTs and JWKs) can be stored in all OSes } catch(e) { console.warn("Could not load native key management. Keys will be stored in plain text."); keytar = require('./keystore-fallback.js').create(opts); @@ -21,6 +22,14 @@ module.exports.create = function (opts) { , delete: function (name) { return keytar.deletePassword(service, name); } + , all: function () { + return keytar.findCredentials(service).then(function (list) { + return list.map(function (el) { + el.password = maybeParse(el.password); + return el; + }); + }); + } , insecure: keytar.insecure }; }; diff --git a/package-lock.json b/package-lock.json index 628f60f..076c11c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,8 +38,7 @@ "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "optional": true + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" }, "aproba": { "version": "1.2.0", @@ -137,14 +136,12 @@ "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "optional": true + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "optional": true + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" }, "core-util-is": { "version": "1.0.2", @@ -236,7 +233,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", - "optional": true, "requires": { "once": "^1.4.0" } @@ -400,7 +396,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -429,10 +424,20 @@ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, + "keyfetch": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/keyfetch/-/keyfetch-1.1.8.tgz", + "integrity": "sha512-a8E1E25mHiv2zZnrBM6WNfQi4hG43TgVg1JG/D61WiTBAM07OJzSuy3j00H2pWPF6MCofBmA+KTzSu145nZWuA==", + "requires": { + "@coolaj86/urequest": "^1.3.6", + "eckles": "^1.4.0", + "rasha": "^1.2.1" + } + }, "keypairs": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/keypairs/-/keypairs-1.2.6.tgz", - "integrity": "sha512-sJDaZvJqHWUawJjrOGKJvKGLfPh0eo2WV7td4RSL88w3BjPYCYI9PkqBn0hLqc6uw0HFSqZMikhGn/jgPpcWnQ==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/keypairs/-/keypairs-1.2.12.tgz", + "integrity": "sha512-zYjYdDvo7G4AIkkZVM3WEJBTRUIrFzYswYNqCxcCPHUsgbBBdewSHAH1CiaQ+VA6Yb7BLEPIv8gFrRz5wJrgsw==", "requires": { "eckles": "^1.4.1", "rasha": "^1.2.4" @@ -597,8 +602,7 @@ "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "optional": true + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" }, "object-assign": { "version": "4.1.1", @@ -617,7 +621,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "optional": true, "requires": { "wrappy": "1" } @@ -951,7 +954,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -970,7 +972,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -1104,8 +1105,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "optional": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "ws": { "version": "6.0.0", diff --git a/package.json b/package.json index 155dae4..354dd8c 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "finalhandler": "^1.1.1", "greenlock": "^2.6.7", "js-yaml": "^3.11.0", - "keypairs": "^1.2.6", + "keyfetch": "^1.1.8", + "keypairs": "^1.2.12", "mkdirp": "^0.5.1", "proxy-packer": "^2.0.2", "ps-list": "^5.0.0",