v1.0.1: export and update docs
This commit is contained in:
parent
1a0e92eb52
commit
738be9b656
114
README.md
114
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:
|
A brief (albeit somewhat nonsensical) introduction to the APIs:
|
||||||
|
|
||||||
```
|
```
|
||||||
Keypairs.generate().then(function (jwk) {
|
Keypairs.generate().then(function (pair) {
|
||||||
return Keypairs.export({ jwk: jwk }).then(function (pem) {
|
return Keypairs.export({ jwk: pair.private }).then(function (pem) {
|
||||||
return Keypairs.import({ pem: pem }).then(function (jwk) {
|
return Keypairs.import({ pem: pem }).then(function (jwk) {
|
||||||
return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) {
|
return Keypairs.thumbprint({ jwk: jwk }).then(function (thumb) {
|
||||||
console.log(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
|
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.
|
_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)`
|
Generates a public/private pair of JWKs as `{ private, public }`
|
||||||
* 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 })`
|
|
||||||
|
|
||||||
<!--
|
Option examples:
|
||||||
|
|
||||||
* `Keypairs.jws.sign(options)`
|
* RSA `{ kty: 'RSA', modulusLength: 2048 }`
|
||||||
* options example `{ keypair, header, protected, payload }`
|
* ECDSA `{ kty: 'ECDSA', namedCurve: 'P-256' }`
|
||||||
* `Keypairs.csr.generate(options)`
|
|
||||||
* options example `{ keypair, [ 'example.com' ] }`
|
|
||||||
|
|
||||||
-->
|
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.
|
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/)
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
120
keypairs.js
120
keypairs.js
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
var Eckles = require('eckles');
|
var Eckles = require('eckles');
|
||||||
var Rasha = require('rasha');
|
var Rasha = require('rasha');
|
||||||
var Keypairs = {};
|
var Enc = {};
|
||||||
|
var Keypairs = module.exports;
|
||||||
|
|
||||||
/*global Promise*/
|
/*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, '');
|
||||||
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "keypairs",
|
"name": "keypairs",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "keypairs",
|
"name": "keypairs",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"description": "Lightweight RSA/ECDSA keypair generation and JWK <-> PEM",
|
"description": "Lightweight RSA/ECDSA keypair generation and JWK <-> PEM",
|
||||||
"main": "keypairs.js",
|
"main": "keypairs.js",
|
||||||
"files": [],
|
"files": [],
|
||||||
|
|
Loading…
Reference in New Issue