From 4cccaa8786904f7fcbc93093be2b0ee21441d285 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Tue, 5 Mar 2019 13:11:13 -0700 Subject: [PATCH] v1.2.1: move cli code into here, add help --- README.md | 6 +- bin/keypairs-cli.js | 687 +++++++++++++++++++++++++++++++++++++++++++- package.json | 2 +- 3 files changed, 691 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 85131d3..8c1f6c4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ The most useful and easy-to-use crypto cli on the planet * [x] Public Key Formats: PKCS1, PKIX (SPKI), SSH * [x] Create JWT tokens * [x] Sign JWT/JWS claims/tokens/payloads -* [x] Verify JWT/JWS tokens/json +* [x] Decode JWTs (without verifying) +* [x] Verify JWT/JWS tokens/json (by fetching public key) # Install @@ -34,9 +35,10 @@ so you should only script to the documented syntax. ;) * Generate: `keypairs gen` * Convert: `keypairs ./priv.pem` -* Sign: `keypairs ./priv.pem sign https://example.com/ '{"sub":"jon@example.com"}'` +* Sign: `keypairs sign ./priv.pem https://example.com/ '{"sub":"jon@example.com"}'` * Verify: `keypairs verify 'xxxxx.yyyyy.zzzzz'` * Decode: `keypairs decode 'xxxxx.yyyyy.zzzzz'` +* Debug: prefix any option with `debug` such as `keypairs debug gen pem key.pem jwk pub.json` ## Generate a New Key diff --git a/bin/keypairs-cli.js b/bin/keypairs-cli.js index cdc00f9..61a2890 100755 --- a/bin/keypairs-cli.js +++ b/bin/keypairs-cli.js @@ -1,4 +1,689 @@ #!/usr/bin/env node 'use strict'; -require("keypairs/bin/keypairs.js"); +// I'm not proud of the way this code is written - it snowballed from a thought +// experiment into a full-fledged CLI, literally overnight (as it it's 4:30am +// right now), but I love what it accomplishes! + +/*global Promise*/ +var fs = require('fs'); +var Rasha = require('rasha'); +var Eckles = require('eckles'); +var Keypairs = require('keypairs'); +var pkg = require('../package.json'); +var kpkg = require('keypairs/package.json'); + +// XXX +// TODO read from pipe as the first argument, if available +// XXX +var args = process.argv.slice(2); +var opts = { keys: [], jwts: [], jwss: [], payloads: [], names: [], filenames: [], files: [] }; +var conflicts = { + 'namedCurve': 'modulusLength' +, 'public': 'private' +}; +Object.keys(conflicts).forEach(function (k) { + conflicts[conflicts[k]] = k; +}); +function set(key, val) { + if (opts[conflicts[key]]) { + console.error("cannot set '" + key + "' to '" + val + "': '" + conflicts[key] + "' already set as '" + opts[conflicts[key]] + "'"); + process.exit(1); + } + if (opts[key]) { + console.error("cannot set '" + key + "' to '" + val + "': already set as '" + opts[key] + "'"); + process.exit(1); + } + opts[key] = val; +} + +// duck type all the things +// TODO segment off by actions (gen, sign, verify) and allow parse/convert or gen before sign +args.forEach(function (arg) { + var larg = arg.toLowerCase().replace(/[^\w]/g, ''); + var narg = parseInt(arg, 10) || 0; + if (narg.toString() !== arg) { + // i.e. 2048.pem is a valid file name + narg = false; + } + + if ('help' === arg) { + helpAndExit(); + return; + } + + if ('version' === arg) { + console.info('%s v%s (%s v%s)', pkg.name, pkg.version, kpkg.name, kpkg.version); + process.exit(0); + return; + } + + if (setTimes(arg)) { + return; + } + if (setIssuer(arg)) { + return; + } + if (setSubject(arg)) { + return; + } + + if ('ecdsa' === larg || 'ec' === larg) { + set('kty', "EC"); + if (opts.modulusLength) { + console.error("EC keys do not have bit lengths such as '" + opts.modulusLength + "'. Choose either the P-256 or P-384 'curve' instead."); + process.exit(1); + } + } + if ('rsa' === larg) { + set('kty', "RSA"); + if (opts.namedCurve) { + console.error("RSA keys do not have curves such as '" + opts.namedCurve + "'. Choose a modulus bit length, such as 2048 instead."); + process.exit(1); + } + return; + } + + // P-384 + if (-1 !== ['256', 'p256', 'prime256v1', 'secp256r1'].indexOf(larg)) { + set('namedCurve', "P-256"); + return; + } + + // P-384 + if (-1 !== ['384', 'p384', 'secp384r1'].indexOf(larg)) { + set('namedCurve', "P-384"); + return; + } + + // RSA Modulus Length + if (narg) { + if (narg < 2048 || narg % 8 || narg > 8192) { + console.error("RSA modulusLength must be >=2048, <=8192 and divisible by 8"); + process.exit(1); + } + set('modulusLength', narg); + return; + } + + // Booleans + if (-1 !== [ 'private', 'public', 'nocompact', 'nofetch', 'debug', 'overwrite' ].indexOf(arg)) { + set(arg, true); + return; + } + if ('uncompressed' === arg) { + set('uncompressed', true); + return; + } + if (-1 !== [ 'gen', 'sign', 'verify', 'decode' ].indexOf(arg)) { + set('action', arg); + return; + } + + // Key format and encoding + if (-1 !== [ 'spki', 'pkix' ].indexOf(larg)) { + set('pubFormat', 'spki'); + return; + } + // TODO add ssh private key support (it's already built in jwk-to-ssh) + if ('ssh' === larg) { + set('pubFormat', 'ssh'); + return; + } + if (-1 !== [ 'openssh', 'sec1', 'pkcs1', 'pkcs8' ].indexOf(larg)) { + // pkcs1 can be public or private, it's ambiguous + if (!opts.privFormat) { + set('privFormat', larg); + return; + } + + if ('pkcs1' === larg || 'ssh' === larg) { + set('pubFormat', larg); + return; + } + if ('openssh' === larg) { + console.warn("specifying 'openssh' twice? ...assuming that you meant 'ssh'"); + set('pubFormat', 'ssh'); + return; + } + if ('pkcs8' === larg) { + console.warn("specifying 'pkcs8' twice? ...assuming that you meant 'spki' (pkix)"); + set('pubFormat', 'spki'); + return; + } + if ('sec1' === larg) { + console.warn("specifying 'sec1' twice? ...assuming that you meant 'spki' (pkix)"); + set('pubFormat', 'spki'); + return; + } + return; + } + if ('jwk' === larg) { + if (!opts.privFormat) { + set('privFormat', larg); + } else { + set('pubFormat', larg); + } + return; + } + if ('pem' === larg || 'der' === larg || 'json' === larg) { + if (!opts.privEncoding) { + set('privEncoding', larg); + } else { + set('pubEncoding', larg); + } + return; + } + + // Filename + try { + fs.accessSync(arg); + opts.filenames.push(arg); + opts.names.push({ taken: true, name: arg }); + if (!guessFile(arg)) { + opts.files.push(arg); + } + return; + } catch(e) { /* not keypath */ } + + // Test for JWK-ness / payload-ness + if (guess(arg)) { + return; + } + + // Test for JWT-ness + if (setJwt(arg)) { + return; + } + + // Possibly the output file + if (opts.names.length < 3) { + opts.names.push({ taken: false, name: arg }); + return; + } + // check if it's a valid output key + + console.error("too many arguments or didn't understand argument '" + arg + "'"); + if (opts.debug) { + console.warn(opts); + } + process.exit(1); +}); + +function guessFile(filename) { + try { + // TODO der support + var txt = fs.readFileSync(filename).toString('utf8'); + return guess(txt, filename); + } catch(e) { + return false; + } +} + +function guess(txt, filename) { + try { + var json = JSON.parse(txt); + if (-1 !== [ 'RSA', 'EC' ].indexOf(json.kty)) { + opts.keys.push({ raw: txt, jwk: json, filename: filename }); + return true; + } else if (json.signature && json.payload && (json.header || json.protected)) { + opts.jwss.push(json); + return true; + } else { + opts.payloads.push(txt); + return true; + } + } catch(e) { + try { + var jwk = Eckles.importSync({ pem: txt }); + // pem._string = txt; + opts.keys.push({ jwk: jwk, pem: true, raw: txt }); + return true; + } catch(e) { + try { + var jwk = Rasha.importSync({ pem: txt }); + // pem._string = txt; + opts.keys.push({ jwk: jwk, pem: true, raw: txt }); + return true; + } catch(e) { + // ignore + } + } + } + return false; +} + +// node bin/keypairs.js debug spki pem json pkcs1 ~/.ssh/id_rsa.pub foo.pem bar.pem 'abc.abc.abc' '{"kty":"EC"}' '{}' '{"signature":"x", "payload":"x", "header":"x"}' '{"signature":"x", "payload":"x", "protected":"x"}' verify +if (opts.debug) { + console.warn(opts); +} + +var kp; + +if ('gen' === opts.action || (!opts.action && !opts.names.length)) { + if (opts.names.length > 2) { + console.error("there should only be two output files at most when generating keypairs"); + console.error(opts.names.map(function (t) { return t.name; })); + process.exit(1); + return; + } + + kp = genKeypair(); +} else if ('decode' === opts.action) { + if (!opts.jwts.length) { + console.error("no JWTs specified to decode"); + process.exit(1); + return; + } + + return Promise.all(opts.jwts.map(function (jwt, i) { + try { + var decoded = decodeJwt(jwt); + console.info("Decoded #" + (i + 1) + ":"); + console.info(JSON.stringify(decoded, null, 2)); + } catch(e) { + console.error("Failed to decode #" + (i + 1) + ":"); + console.error(e); + } + })); +} else if ('verify' === opts.action || (!opts.action && opts.jwts.length)) { + if (!opts.jwts.length) { + console.error("no JWTs specified to verify"); + process.exit(1); + return; + } + + return Promise.all(opts.jwts.map(function (jwt, i) { + return require('keyfetch').verify({ jwt: jwt }).then(function (decoded) { + console.info("Verified #" + (i + 1) + ":"); + console.info(JSON.stringify(decoded, null, 2)); + }).catch(function (err) { + console.error("Failed to verify #" + (i + 1) + ":"); + console.error(err); + }); + })); +} else { + if (opts.names.length > 3) { + console.error("there should only be one input file and up to two output files when converting keypairs"); + console.error(opts.names.map(function (t) { return t.name; })); + process.exit(1); + return; + } + var pair = readKeypair(); + pair._convert = true; + kp = Promise.resolve(pair); +} + +if ('sign' === opts.action) { + return kp.then(function (pair) { + var jwk = pair.private; + if (!jwk || !jwk.d) { + console.error("the first key was not a private key"); + console.error(opts.names.map(function (t) { return t.name; })); + process.exit(1); + return; + } + if (!opts.payloads.length) { + opts.payloads.push('{}'); + } + return Promise.all(opts.payloads.map(function (payload) { + var claims = JSON.parse(payload); + if (!claims.iss) { claims.iss = opts.issuer; } + if (!claims.iss) { console.warn("No issuer given, token will not be verifiable"); } + if (!claims.sub) { claims.sub = opts.sub; } + if (!claims.exp) { + if (!opts.expiresAt) { setTimes('15m'); } + claims.exp = opts.expiresAt; + } + if (!claims.iat) { claims.iat = opts.issuedAt; } + if (!claims.nbf) { claims.nbf = opts.nbf; } + return Keypairs.signJwt({ jwk: pair.private, claims: claims }).then(function (jwt) { + console.info(jwt); + }); + })); + }); +} else { + return kp.then(function (pair) { + if (pair._convert) { + return convertKeypair(pair); + } + }); +} + +function readKeypair() { + // note that the jwk may be a string + var keyopts = opts.keys.shift(); + var jwk = keyopts && keyopts.jwk; + if (!jwk) { + console.error("no keys could be parsed from the given arguments"); + console.error(opts.names.map(function (t) { return t.name; })); + process.exit(1); + return; + } + + // omit the primary private key from the list of actual (or soon-to-be) files + if (keyopts.filename) { + opts.names = opts.names.filter(function (name) { + return name.name !== keyopts.filename; + }); + } + + var pair = { private: null, public: null, pem: keyopts.pem, raw: keyopts.raw }; + if (jwk.d) { + pair.private = jwk; + } + pair.public = Keypairs._neuter({ jwk: jwk }); + return pair; +} + +// Note: some of the conditions can be factored out +// this was all built in high-speed iterative during the 3ams+ +function convertKeypair(pair) { + //var pair = readKeypair(); + + // XXX + // TODO detect desired format via extension(s) (pem, der, json, jwk) + // XXX + + var ps = []; + // if it's private only, or if it's not public-only, produce the private key + if (pair.private || !opts.public) { + // if it came from pem (or is explicitly json), it should go to jwk + // otherwise, if it came from jwk, it should go to pem + if (((!opts.privEncoding && pair.pem) || 'json' === opts.privEncoding) + && ((!opts.privFormat && pair.pem) || 'jwk' === opts.privFormat)) { + ps.push(Promise.resolve(pair.private)); + } else { + ps.push(Keypairs.export({ jwk: pair.private, format: opts.privFormat, encoding: opts.privEncoding })); + } + } + + // if it's not private key only, we want to produce the public key + if (!opts.private) { + if (opts.public) { + // if it's public-only the ambigious options will fall to the private key + // so we need to fix that + if (!opts.pubFormat) { opts.pubFormat = opts.privFormat; } + if (!opts.pubEncoding) { opts.pubEncoding = opts.privEncoding; } + } + + // same as above - swap formats by default + if (((!opts.pubEncoding && pair.pem) || 'json' === opts.pubEncoding) + && ((!opts.pubFormat && pair.pem) || 'jwk' === opts.pubFormat)) { + ps.push(Promise.resolve(pair.public)); + } else { + ps.push(Keypairs.export({ jwk: pair.public, format: opts.pubFormat, encoding: opts.pubEncoding, public: true })); + } + } + + return Promise.all(ps).then(function (exported) { + // start with the first key, annotating if it should be public + var index = 0; + var key = stringifyIfJson(index, opts.public); + + // re: opts.names + // if we're only doing the public key we can end early + // (if the source key was from a file and was in opts.names, + // we're safe here because we already removed it earlier) + + if (opts.public) { + if (opts.names.length) { + writeFile(opts.names[index].name, key, !opts.public); + } else { + // output public keys to stderr + printPublic(key); + } + // end <-- we're not outputting other keys + return; + } + + // private key stuff + if (opts.names.length >= 1) { + writeFile(opts.names[index].name, key, true); + } else { + printPrivate(key); + } + + // pub key stuff + // we have to output the private key, + // but the public key can be derived at any time + // so we don't need to put the same noise to the screen + if (!opts.private && opts.names.length >= 2) { + index = 1; + key = stringifyIfJson(index, false); + writeFile(opts.names[index].name, key, false); + } + + return pair; + + function stringifyIfJson(i, pub) { + if (exported[i].kty) { + if (pub) { + if (opts.expiresAt) { exported[i].exp = opts.expiresAt; } + exported[i].use = "sig"; + } + exported[i] = JSON.stringify(exported[i]); + } + return exported[i]; + } + }); +} + +function genKeypair() { + return Keypairs.generate({ + kty: opts.kty + , modulusLength: opts.modulusLength + , namedCurve: opts.namedCurve + }).then(function (pair) { + // always generate as jwk by default + var ps = []; + if ((!opts.privEncoding || 'json' === opts.privEncoding) && (!opts.privFormat || 'jwk' === opts.privFormat)) { + ps.push(Promise.resolve(pair.private)); + } else { + ps.push(Keypairs.export({ jwk: pair.private, format: opts.privFormat, encoding: opts.privEncoding })); + } + if ((!opts.pubEncoding || 'json' === opts.pubEncoding) && (!opts.pubFormat || 'jwk' === opts.pubFormat)) { + ps.push(Promise.resolve(pair.public)); + } else { + ps.push(Keypairs.export({ jwk: pair.public, format: opts.pubFormat, encoding: opts.pubEncoding, public: true })); + } + return Promise.all(ps).then(function (arr) { + if (arr[0].kty) { + arr[0] = JSON.stringify(arr[0]); + } + if (arr[1].kty) { + if (opts.expiresAt) { arr[1].exp = opts.expiresAt; } + arr[1].use = "sig"; + arr[1] = JSON.stringify(arr[1]); + } + if (!opts.names.length) { + console.info(arr[0] + "\n"); + console.warn(arr[1] + "\n"); + } + if (opts.names.length >= 1) { + writeFile(opts.names[0].name, arr[0], true); + if (!opts.private && opts.names.length >= 2) { + writeFile(opts.names[1].name, arr[1]); + } + } + + return pair; + }); + }); +} + +function writeFile(name, key, priv) { + var overwrite; + try { + fs.accessSync(name); + overwrite = opts.overwrite; + if (!opts.overwrite) { + if (priv) { + // output private keys to stdout + console.info(key + "\n"); + } else { + // output public keys to stderr + console.warn(key + "\n"); + } + console.error("'" + name + "' exists! force overwrite with 'overwrite'"); + process.exit(1); + return; + } + } catch(e) { + // the file does not exist (or cannot be accessed) + } + fs.writeFileSync(name, key); + if (overwrite) { + console.info("Overwrote " + (priv ? "private" : "public") + " key at '" + name + "'"); + } else { + console.info("Wrote " + (priv ? "private" : "public") + " key to '" + name + "'"); + } +} + +function setJwt(arg) { + try { + var jwt = arg.match(/^([\w-]+)\.([\w-]+)\.([\w-]+)$/); + // make sure header is a JWT header + JSON.parse(Buffer.from(jwt[1], 'base64')); + opts.jwts.push(arg); + return true; + } catch(e) { + // ignore + } +} + +function setSubject(arg) { + if (!/.+@[a-z0-9_-]+\.[a-z0-9_-]+/i.test(arg)) { + return false; + } + + opts.subject = arg; + return false; +} + +function setIssuer(arg) { + if (!/^https?:\/\/[a-z0-9_-]+\.[a-z0-9_-]+/i.test(arg)) { + return false; + } + + try { + new URL(arg); + opts.issuer = arg.replace(/\/$/, ''); + return true; + } catch(e) { + } + return false; +} + +function setTimes(arg) { + var t = arg.match(/^(\-?\d+)([dhms])$/i); + if (!t || !t[0]) { + return false; + } + + var num = parseInt(t[1], 10); + var unit = t[2]; + var mult = 1; + opts.issuedAt = Math.round(Date.now()/1000); + 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; + } + if (!opts.expiresIn) { + opts.expiresIn = mult * num; + opts.expiresAt = opts.issuedAt + opts.expiresIn; + } else { + opts.nbf = opts.issuedAt + (mult * num); + } + return true; +} + +function decodeJwt(jwt) { + var parts = jwt.split('.'); + return { + header: JSON.parse(Buffer.from(parts[0], 'base64')) + , payload: JSON.parse(Buffer.from(parts[1], 'base64')) + , signature: parts[2] //Buffer.from(parts[2], 'base64') + }; +} + +function printPrivate(key) { + console.info(key + "\n"); +} +function printPublic(key) { + console.warn(key + "\n"); +} + +function helpAndExit() { + console.info(); + console.info('## Generate Keys'); + console.info('keypairs gen [key-opts] [[private-format] [private-key-file]] [[public-format] [public-key-file]]'); + console.info('\tkeypairs gen'); + console.info('\tkeypairs gen ec P-256'); + console.info('\tkeypairs gen rsa 2048'); + console.info('\tkeypairs gen key.json pub.json'); + console.info('\tkeypairs gen pem key.pem'); + console.info('\tkeypairs gen sec1 key.pem ssh id.pub'); + console.info(); + + console.info('## Parse and Convert Keys'); + console.info('keypairs [key-file-or-string] [[private-format] [private-key-file]] [[public-format] [public-key-file]]'); + console.info('\tkeypairs key.pem # PEM-to-JWK'); + console.info('\tkeypairs \'{"kty":"EC"...}\' key.pem # JWK-to-PEM'); + console.info('\tkeypairs key.pem spki pub.pem # PEM-to-PEM'); + console.info('\tkeypairs id_rsa.pub spki # SSH-to-PEM'); + console.info('\tkeypairs key.pem ssh id_rsa.pub # PEM-to-SSH'); + console.info('\tkeypairs key.json pem id_rsa ssh id_rsa.pub # JWK-to-SSH'); + console.info('\tkeypairs id_rsa.pub pub.json # SSH-to-JWK'); + console.info(); + + // XXX + // TODO test fetch the public keys from the issuer url + // (report warning if not found or if kid is not thumbprint) + // XXX + console.info('## Sign JWTs'); + console.info('keypairs sign [key-file-or-string] [issuer-url] [subject-email] [claims-json] [exp] [nbf]'); + console.info('\tkeypairs sign key.pem \'{"iss":"https://example.com/"}\' 1h'); + console.info('\tkeypairs sign key.json https://example.com/ jon@gmail.com 15m'); + console.info('\tkeypairs sign \'{"kty":"RSA"...}\' https://example.com \'{"foo":"bar"}\''); + console.info('\tkeypairs sign key.pem https://example.com/ 3d 30s'); + console.info('\tkeypairs sign key.pem https://example.com/ \'{"sub":"jon@gmail.com"}\''); + console.info(); + + console.info('## Decode JWTs (without verification)'); + console.info('keypairs decode [jwt-file-or-string]'); + console.info('\tkeypairs decode \'xxx.yyy.zzz\''); + console.info('\tkeypairs decode token.jwt'); + console.info(); + + console.info('## Verify JWTs (by fetching public key)'); + console.info('keypairs verify [jwt-file-or-string]'); + console.info('\tkeypairs verify \'xxx.yyy.zzz\''); + console.info('\tkeypairs verify token.jwt'); + console.info(); + + // XXX + // TODO thumbprint + // XXX + + console.info('-----'); + console.info(); + console.info('Keys Options:'); + console.info('\trsa 2048 3072 4096 8192'); + console.info('\tec P-256 P-384'); + console.info(); + console.info('Schemas: jwk, pkcs1, sec1, pkcs8, spki (pkix), ssh'); + console.info(); + console.info('Encodings: json, pem'); + process.exit(0); +} diff --git a/package.json b/package.json index 75a0109..023a288 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keypairs-cli", - "version": "1.2.0", + "version": "1.2.1", "description": "CLI for Keypairs.js", "homepage": "https://git.coolaj86.com/coolaj86/keypairs-cli.js", "main": "bin/keypairs-cli.js",