diff --git a/README.md b/README.md index 5353774..39dd028 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,19 @@ and [Rasha.js (RSA)](https://git.coolaj86.com/coolaj86/rasha.js/). A brief (albeit somewhat nonsensical) introduction to the APIs: ``` -Keypairs.generate().then(function (jwk) { - return Keypairs.export({ jwk: jwk }).then(function (pem) { +Keypairs.generate().then(function (pair) { + return Keypairs.export({ jwk: pair.private }).then(function (pem) { return Keypairs.import({ pem: pem }).then(function (jwk) { return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) { console.log(thumb); + return Keypairs.signJwt({ + jwk: keypair.private + , claims: { + iss: 'https://example.com' + , sub: 'jon.doe@gmail.com' + , exp: Math.round(Date.now()/1000) + (3 * 24 * 60 * 60) + } + }); }); }); }); @@ -44,36 +52,94 @@ Keypairs.generate().then(function (jwk) { By default ECDSA keys will be used since they've had native support in node _much_ longer than RSA has, and they're smaller, and faster to generate. -## API +## API Overview -Each of these return a Promise. +#### Keypairs.generate(options) -* `Keypairs.generate(options)` - * options example `{ kty: 'RSA', modulusLength: 2048 }` - * options example `{ kty: 'ECDSA', namedCurve: 'P-256' }` -* `Keypairs.import(options)` - * options example `{ pem: '...' }` -* `Keypairs.export(options)` - * options example `{ jwk: jwk }` - * options example `{ jwk: jwk, public: true }` -* `Keypairs.thumbprint({ jwk: jwk })` +Generates a public/private pair of JWKs as `{ private, public }` - +When no options are supplied EC P-256 (also known as `prime256v1` and `secp256r1`) is used by default. -# Full Documentation +#### Keypairs.import({ pem: '...' } -Keypairs.js provides a 1-to-1 mapping to the Rasha.js and Eckles.js APIs. +Takes a PEM in pretty much any format (PKCS1, SEC1, PKCS8, SPKI) and returns a JWK. -The full RSA documentation is at [Rasha.js](https://git.coolaj86.com/coolaj86/rasha.js/) +#### Keypairs.export(options) -The full ECDSA documentation is at [Eckles.js](https://git.coolaj86.com/coolaj86/eckles.js/) +Exports a JWK as a PEM. -Any option you pass to Keypairs will be passed directly to the corresponding API +Exports PEM in PKCS8 (private) or SPKI (public) by default. + +Options + +```js +{ jwk: jwk +, public: true +, encoding: 'pem' // or 'der' +, format: 'pkcs8' // or 'ssh', 'pkcs1', 'sec1', 'spki' +} +``` + +#### Keypairs.thumbprint({ jwk: jwk }) + +Promises a JWK-spec thumbprint: URL Base64-encoded sha256 + +#### Keypairs.signJwt({ jwk, header, claims }) + +Returns a JWT (otherwise known as a protected JWS in "compressed" format). + +```js +{ jwk: jwk +, claims: { + } +} +``` + +Header defaults: + +```js +{ kid: thumbprint +, alg: 'xS256' +, typ: 'JWT' +} +``` + +Payload notes: + +* `iat: now` is added by default (set `false` to disable) +* `exp` must be set (set `false` to disable) +* `iss` should be the base URL for JWK lookup (i.e. via OIDC, Auth0) + +Notes: + +`header` is actually the JWS `protected` value, as all JWTs use protected headers (yay!) +and `claims` are really the JWS `payload`. + +#### Keypairs.signJws({ jwk, header, protected, payload }) + +This is provided for APIs like ACME (Let's Encrypt) that use uncompressed JWS (instead of JWT, which is compressed). + +Options: + +* `header` not what you think. Leave undefined unless you need this for the spec you're following. +* `protected` is the typical JWT-style header + * `kid` and `alg` will be added by default (these are almost always required), set `false` explicitly to disable +* `payload` can be JSON, a string, or even a buffer (which gets URL Base64 encoded) + * you must set this to something, even if it's an empty string, object, or Buffer + +# 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`. + +That is to say that any option you pass to Keypairs will be passed directly to the corresponding API of either Rasha or Eckles. + +* 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/example.js b/example.js new file mode 100644 index 0000000..f2563a0 --- /dev/null +++ b/example.js @@ -0,0 +1,33 @@ +'use strict'; + +var Keypairs = require('./keypairs.js'); +var Keyfetch = require('keyfetch'); + +Keypairs.generate().then(function (keypair) { + return Keypairs.thumbprint({ jwk: keypair.public }).then(function (thumb) { + var iss = 'https://coolaj86.com/'; + + // shim so that no http request is necessary + keypair.private.kid = thumb; + Keyfetch._setCache(iss, { thumbprint: thumb, jwk: keypair.private }); + + return Keypairs.signJwt({ + jwk: keypair.private + , claims: { + iss: iss + , sub: 'coolaj86@gmail.com' + , exp: Math.round(Date.now()/1000) + (3 * 24 * 60 * 60) + } + }); + }); +}).then(function (jwt) { + console.log(jwt); + return Keyfetch.verify({ jwt: jwt }).then(function (ok) { + if (!ok) { + throw new Error("SANITY: did not verify (should have failed)"); + } + console.log("Verified token"); + }); +}).catch(function (err) { + console.error(err); +}); diff --git a/keypairs.js b/keypairs.js index c0f1104..e33dcc5 100644 --- a/keypairs.js +++ b/keypairs.js @@ -2,7 +2,8 @@ var Eckles = require('eckles'); var Rasha = require('rasha'); -var Keypairs = {}; +var Enc = {}; +var Keypairs = module.exports; /*global Promise*/ @@ -40,3 +41,120 @@ Keypairs.thumbprint = function (opts) { } }); }; + +// JWT a.k.a. JWS with Claims using Compact Serialization +Keypairs.signJwt = function (opts) { + return Keypairs.thumbprint({ jwk: opts.jwk }).then(function (thumb) { + var header = opts.header || {}; + var claims = JSON.parse(JSON.stringify(opts.claims || {})); + header.typ = 'JWT'; + if (!header.kid) { + header.kid = thumb; + } + if (false === claims.iat) { + claims.iat = undefined; + } else if (!claims.iat) { + claims.iat = Math.round(Date.now()/1000); + } + if (false === claims.exp) { + claims.exp = undefined; + } else if (!claims.exp) { + throw new Error("opts.claims.exp should be the expiration date (as seconds since the Unix epoch) or false"); + } + if (false === claims.iss) { + claims.iss = undefined; + } else if (!claims.iss) { + throw new Error("opts.claims.iss should be in the form of https://example.com/, a secure OIDC base url"); + } + + return Keypairs.signJws({ + jwk: opts.jwk + , pem: opts.pem + , protected: header + , header: undefined + , payload: claims + }).then(function (jws) { + return [ jws.protected, jws.payload, jws.signature ].join('.'); + }); + }); +}; + +Keypairs.signJws = function (opts) { + return Keypairs.thumbprint(opts).then(function (thumb) { + + function alg() { + if (!opts.jwk) { + throw new Error("opts.jwk must exist and must declare 'typ'"); + } + return ('RSA' === opts.jwk.typ) ? "RS256" : "ES256"; + } + + function sign(pem) { + var header = opts.header; + var protect = opts.protected; + var payload = opts.payload; + + // Compute JWS signature + var protectedHeader = ""; + // Because unprotected headers are allowed, regrettably... + // https://stackoverflow.com/a/46288694 + if (false !== protect) { + if (!protect) { protect = {}; } + if (!protect.alg) { protect.alg = alg(); } + // There's a particular request where Let's Encrypt explicitly doesn't use a kid + if (!protect.kid && false !== protect.kid) { protect.kid = thumb; } + protectedHeader = JSON.stringify(protect); + } + + // Convert payload to Buffer + if ('string' !== typeof payload && !Buffer.isBuffer(payload)) { + if (!payload) { + throw new Error("opts.payload should be JSON, string, or Buffer (it may be empty, but that must be explicit)"); + } + payload = JSON.stringify(payload); + } + if ('string' === typeof payload) { + payload = Buffer.from(payload, 'binary'); + } + + // node specifies RSA-SHAxxx even whet it's actually ecdsa (it's all encoded x509 shasums anyway) + var nodeAlg = "RSA-SHA" + (((protect||header).alg||'').replace(/^[^\d]+/, '')||'256'); + var protected64 = Enc.strToUrlBase64(protectedHeader); + var payload64 = Enc.bufToUrlBase64(payload); + var sig = require('crypto') + .createSign(nodeAlg) + .update(protect ? (protected64 + "." + payload64) : payload64) + .sign(pem, 'base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + ; + + return { + header: header + , protected: protected64 || undefined + , payload: payload64 + , signature: sig + }; + } + + if (opts.pem && opts.jwk) { + return sign(opts.pem); + } else { + return Keypairs.export({ jwk: opts.jwk }).then(sign); + } + }); +}; + +Enc.strToUrlBase64 = function (str) { + // node automatically can tell the difference + // between uc2 (utf-8) strings and binary strings + // so we don't have to re-encode the strings + return Buffer.from(str).toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; +Enc.bufToUrlBase64 = function (buf) { + // allow for Uint8Array as a Buffer + return Buffer.from(buf).toString('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; diff --git a/package-lock.json b/package-lock.json index b993747..f962f94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "keypairs", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index a1a8a8c..3b28714 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "keypairs", - "version": "1.0.0", + "version": "1.0.1", "description": "Lightweight RSA/ECDSA keypair generation and JWK <-> PEM", "main": "keypairs.js", "files": [],