From 7099943db7db37d1f9ddfcf5fd0ec1ec37520a89 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 4 Mar 2019 17:16:25 -0700 Subject: [PATCH] v1.1.0: Add tests, more convenience methods, more docs --- README.md | 74 +++++++++++++++++++++++++++++--- keypairs.js | 80 +++++++++++++++++++++++++++++++++- package-lock.json | 14 +++--- package.json | 8 ++-- test.js | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 263 insertions(+), 19 deletions(-) create mode 100644 test.js diff --git a/README.md b/README.md index 39dd028..0910184 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Keypairs for node.js +# Keypairs.js Lightweight JavaScript RSA and ECDSA utils that work on Windows, Mac, and Linux using modern node.js APIs (no need for C compiler). @@ -13,6 +13,7 @@ and [Rasha.js (RSA)](https://git.coolaj86.com/coolaj86/rasha.js/). * [x] ECDSA (P-256, P-384) * [x] PEM-to-JWK * [x] JWK-to-PEM + * [x] Create JWTs (and sign JWS) * [x] SHA256 JWK Thumbprints * [ ] JWK fetching. See [Keyfetch.js](https://npmjs.com/packages/keyfetch/) * [ ] OIDC @@ -20,7 +21,6 @@ and [Rasha.js (RSA)](https://git.coolaj86.com/coolaj86/rasha.js/). @@ -54,6 +54,16 @@ _much_ longer than RSA has, and they're smaller, and faster to generate. ## API Overview +* generate +* parse +* parseOrGenerate +* import (PEM-to-JWK) +* export (JWK-to-PEM, private or public) +* publish (Private JWK to Public JWK) +* thumbprint (JWK SHA256) +* signJwt +* signJws + #### Keypairs.generate(options) Generates a public/private pair of JWKs as `{ private, public }` @@ -65,6 +75,50 @@ Option examples: When no options are supplied EC P-256 (also known as `prime256v1` and `secp256r1`) is used by default. +#### Keypairs.parse(options) + +Parses either a JWK (encoded as JSON) or an x509 (encdode as PEM) and gives +back the JWK representation. + +Option Examples: + +* JWK { key: '{ "kty":"EC", ... }' } +* PEM { key: '-----BEGIN PRIVATE KEY-----\n...' } +* Public Key Only { key: '-----BEGIN PRIVATE KEY-----\n...', public: true } +* Must Have Private Key { key: '-----BEGIN PUBLIC KEY-----\n...', private: true } + +Example: + +```js +Keypairs.parse({ key: '...' }).catch(function (e) { + // could not be parsed or was a public key + console.warn(e); + return Keypairs.generate(); +}); +``` + +#### Keypairs.parseOrGenerate({ key, throw, [generate opts]... }) + +Parses the key. Logs a warning on failure, marches on. +(a shortcut for the above, with `private: true`) + +Option Examples: + +* parse key if exist, otherwise generate `{ key: process.env["PRIVATE_KEY"] }` +* generated key curve `{ key: null, namedCurve: 'P-256' }` +* generated key modulus `{ key: null, modulusLength: 2048 }` + +Example: + +```js +Keypairs.parseOrGenerate({ key: process.env["PRIVATE_KEY"] }).then(function (pair) { + console.log(pair.public); +}) +``` + +Great for when you have a set of shared keys for development and randomly +generated keys in + #### Keypairs.import({ pem: '...' } Takes a PEM in pretty much any format (PKCS1, SEC1, PKCS8, SPKI) and returns a JWK. @@ -85,6 +139,10 @@ Options } ``` +#### Keypairs.publish({ jwk: jwk }) + +**Synchronously** strips a key of its private parts and returns the public version. + #### Keypairs.thumbprint({ jwk: jwk }) Promises a JWK-spec thumbprint: URL Base64-encoded sha256 @@ -134,11 +192,15 @@ Options: # Additional Documentation -Keypairs.js provides a 1-to-1 mapping to the Rasha.js and Eckles.js APIs, -but it also includes the additional convenience methods `signJwt` and `signJws`. +Keypairs.js provides a 1-to-1 mapping to the Rasha.js and Eckles.js APIs for the following: -That is to say that any option you pass to Keypairs will be passed directly to the corresponding API -of either Rasha or Eckles. +* generate(options) +* import({ pem: '---BEGIN...' }) +* export({ jwk: { kty: 'EC', ... }) +* thumbprint({ jwk: jwk }) + +If you want to know the algorithm-specific options that are available for those +you'll want to take a look at the corresponding documentation: * See ECDSA documentation at [Eckles.js](https://git.coolaj86.com/coolaj86/eckles.js/) * See RSA documentation at [Rasha.js](https://git.coolaj86.com/coolaj86/rasha.js/) diff --git a/keypairs.js b/keypairs.js index e33dcc5..5f3b567 100644 --- a/keypairs.js +++ b/keypairs.js @@ -16,9 +16,64 @@ Keypairs.generate = function (opts) { return Eckles.generate(opts); }; +Keypairs.parse = function (opts) { + opts = opts || {}; + + var err; + var jwk; + var pem; + var p; + + try { + jwk = JSON.parse(opts.key); + p = Keypairs.export({ jwk: jwk }).catch(function (e) { + pem = opts.key; + err = new Error("Not a valid jwk '" + JSON.stringify(jwk) + "':" + e.message); + err.code = "EINVALID"; + return Promise.reject(err); + }).then(function () { + return jwk; + }); + } catch(e) { + p = Keypairs.import({ pem: opts.key }).catch(function (e) { + err = new Error("Could not parse key (type " + typeof opts.key + ") '" + opts.key + "': " + e.message); + err.code = "EPARSE"; + return Promise.reject(err); + }); + } + + return p.then(function (jwk) { + var pubopts = JSON.parse(JSON.stringify(opts)); + pubopts.jwk = jwk; + return Keypairs.publish(pubopts).then(function (pub) { + // 'd' happens to be the name of a private part of both RSA and ECDSA keys + if (opts.public || opts.publish || !jwk.d) { + if (opts.private) { + // TODO test that it can actually sign? + err = new Error("Not a private key '" + JSON.stringify(jwk) + "'"); + err.code = "ENOTPRIVATE"; + return Promise.reject(err); + } + return { public: pub }; + } else { + return { private: jwk, public: pub }; + } + }); + }); +}; + +Keypairs.parseOrGenerate = function (opts) { + if (!opts.key) { return Keypairs.generate(opts); } + opts.private = true; + return Keypairs.parse(opts).catch(function (e) { + console.warn(e.message); + return Keypairs.generate(opts); + }); +}; + Keypairs.import = function (opts) { - return Eckles.import(opts.pem).catch(function () { - return Rasha.import(opts.pem); + return Eckles.import(opts).catch(function () { + return Rasha.import(opts); }); }; @@ -32,6 +87,27 @@ Keypairs.export = function (opts) { }); }; +Keypairs.publish = function (opts) { + if ('object' !== typeof opts.jwk || !opts.jwk.kty) { throw new Error("invalid jwk: " + JSON.stringify(opts.jwk)); } + + // trying to find the best balance of an immutable copy with custom attributes + var jwk = {}; + Object.keys(opts.jwk).forEach(function (k) { + // 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])); + }); + + if (!jwk.exp) { + if (opts.expiresIn) { jwk.exp = Math.round(Date.now()/1000) + opts.expiresIn; } + else { 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; }); +}; + Keypairs.thumbprint = function (opts) { return Promise.resolve().then(function () { if ('RSA' === opts.jwk.kty) { diff --git a/package-lock.json b/package-lock.json index f962f94..4ae54b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "keypairs", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { "eckles": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.0.tgz", - "integrity": "sha512-Bm5dpwhsBuoCHvKCY3gAvP8XFyXH7im8uAu3szykpVNbFBdC+lOuV8vLC8fvTYRZBfFqB+k/P6ud/ZPVO2V2tA==" + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/eckles/-/eckles-1.4.1.tgz", + "integrity": "sha512-auWyk/k8oSkVHaD4RxkPadKsLUcIwKgr/h8F7UZEueFDBO7BsE4y+H6IMUDbfqKIFPg/9MxV6KcBdJCmVVcxSA==" }, "rasha": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.1.tgz", - "integrity": "sha512-cs4Hu/rVF3/Qucq+V7lxSz449VfHNMVXJaeajAHno9H5FC1PWlmS4NM6IAX5jPKFF0IC2rOdHdf7iNxQuIWZag==" + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rasha/-/rasha-1.2.4.tgz", + "integrity": "sha512-GsIwKv+hYSumJyK9wkTDaERLwvWaGYh1WuI7JMTBISfYt13TkKFU/HFzlY4n72p8VfXZRUYm0AqaYhkZVxOC3Q==" } } } diff --git a/package.json b/package.json index 3b28714..460db95 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "keypairs", - "version": "1.0.1", + "version": "1.1.0", "description": "Lightweight RSA/ECDSA keypair generation and JWK <-> PEM", "main": "keypairs.js", "files": [], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node test.js" }, "repository": { "type": "git", @@ -21,7 +21,7 @@ "author": "AJ ONeal (https://coolaj86.com/)", "license": "MPL-2.0", "dependencies": { - "eckles": "^1.4.0", - "rasha": "^1.2.1" + "eckles": "^1.4.1", + "rasha": "^1.2.4" } } diff --git a/test.js b/test.js new file mode 100644 index 0000000..c10a4aa --- /dev/null +++ b/test.js @@ -0,0 +1,106 @@ +var Keypairs = require('./'); + +/* global Promise*/ +Keypairs.parseOrGenerate({ key: '' }).then(function (pair) { + // should NOT have any warning output + if (!pair.private || !pair.public) { + throw new Error("missing key pairs"); + } + + return Promise.all([ + // Testing Public Part of key + Keypairs.export({ jwk: pair.public }).then(function (pem) { + if (!/--BEGIN PUBLIC/.test(pem)) { + throw new Error("did not export public pem"); + } + return Promise.all([ + Keypairs.parse({ key: pem }).then(function (pair) { + if (pair.private) { + throw new Error("shouldn't have private part"); + } + return true; + }) + , Keypairs.parse({ key: pem, private: true }).then(function () { + var err = new Error("should have thrown an error when private key was required and public pem was given"); + err.code = 'NOERR'; + throw err; + }).catch(function (e) { + if ('NOERR' === e.code) { throw e; } + return true; + }) + ]).then(function () { + return true; + }); + }) + // Testing Private Part of Key + , Keypairs.export({ jwk: pair.private }).then(function (pem) { + if (!/--BEGIN .*PRIVATE KEY--/.test(pem)) { + throw new Error("did not export private pem: " + pem); + } + return Promise.all([ + Keypairs.parse({ key: pem }).then(function (pair) { + if (!pair.private) { + throw new Error("should have private part"); + } + if (!pair.public) { + throw new Error("should have public part also"); + } + return true; + }) + , Keypairs.parse({ key: pem, public: true }).then(function (pair) { + if (pair.private) { + throw new Error("should NOT have private part"); + } + if (!pair.public) { + throw new Error("should have the public part though"); + } + return true; + }) + ]).then(function () { + return true; + }); + }) + , Keypairs.parseOrGenerate({ key: 'not a key', public: true }).then(function (pair) { + // SHOULD have warning output + if (!pair.private || !pair.public) { + throw new Error("missing key pairs (should ignore 'public')"); + } + return true; + }) + , Keypairs.parse({ key: JSON.stringify(pair.private) }).then(function (pair) { + if (!pair.private || !pair.public) { + throw new Error("missing key pairs (stringified jwt)"); + } + return true; + }) + , Keypairs.parse({ key: JSON.stringify(pair.private), public: true }).then(function (pair) { + if (pair.private) { + throw new Error("has private key when it shouldn't"); + } + if (!pair.public) { + throw new Error("doesn't have public key when it should"); + } + return true; + }) + , Keypairs.parse({ key: JSON.stringify(pair.public), private: true }).then(function () { + var err = new Error("should have thrown an error when private key was required and public jwk was given"); + err.code = 'NOERR'; + throw err; + }).catch(function (e) { + if ('NOERR' === e.code) { throw e; } + return true; + }) + ]).then(function (results) { + if (results.length && results.every(function (v) { return true === v; })) { + console.info("If a warning prints right above this, it's a pass"); + console.log("PASS"); + process.exit(0); + } else { + throw new Error("didn't get all passes (but no errors either)"); + } + }); +}).catch(function (e) { + console.error("Caught an unexpected (failing) error:"); + console.error(e); + process.exit(1); +});