diff --git a/README.md b/README.md index a33d6d7..7b60057 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,76 @@ -eckles.js +[Eckles.js](https://git.coolaj86.com/coolaj86/eckles.js) ========= Sponsored by [Root](https://therootcompany.com). Built for [ACME.js](https://git.coolaj86.com/coolaj86/acme.js) and [Greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js) +| < 600 lines of code | 3kb gzipped | 10kb minified | 17kb with comments | + ECDSA (elliptic curve) tools. Lightweight. Zero Dependencies. Universal compatibility. +* [x] Fast and Easy EC Key Generation * [x] PEM-to-JWK * [x] JWK-to-PEM * [x] SSH "pub" format +* [x] CLI +* [ ] RSA + * **Need RSA tools?** Check out [Rasha.js](https://git.coolaj86.com/coolaj86/rasha.js) -This project is fully functional and tested (and the code is pretty clean). +## Install -It is considered to be complete, but if you find a bug please open an issue. +For node.js: + +```bash +npm install --save eckles +``` + +CLI: + +```bash +npm install -g eckles +``` + +## Generate EC (ECDSA/ECDH) Key + +Achieves the *fastest possible key generation* using node's native EC bindings to OpenSSL, +then converts to JWK for ease-of-use. + +```js +Eckles.generate({ format: 'jwk' }).then(function (keypair) { + console.log(keypair.private); + console.log(keypair.public); +}); +``` + +**options** + +* `format` defaults to `'jwk'` + * `'sec1'` (traditional) + * `'pkcs8'` + * `'ssh'` +* `encoding` defaults to `'json'` + * `'pem'` (type + DER.toString('base64')) + * `'der'` + +**advanced options** + +* `namedCurve` defaults to `'P-256'` + * `P-384` is also supported + * larger keys have not been implemented + * A) because they're a senseless waste + * B) they have similar, but slightly different formats + +#### Generate EC Key CLI + +```bash +# Generate a key in each format +# eckles [format] [curve|encoding] +eckles jwk +eckles sec1 pem +eckles pkcs8 der +eckles ssh P-256 +``` ## PEM-to-JWK @@ -22,11 +79,11 @@ It is considered to be complete, but if you find a bug please open an issue. * [x] SSH (RFC4716), (RFC 4716/SSH2) ```js -var eckles = require('eckles'); +var Eckles = require('eckles'); var pem = require('fs') .readFileSync('./node_modles/eckles/fixtures/privkey-ec-p256.sec1.pem', 'ascii'); -eckles.import({ pem: pem }).then(function (jwk) { +Eckles.import({ pem: pem }).then(function (jwk) { console.log(jwk); }); ``` @@ -41,6 +98,17 @@ eckles.import({ pem: pem }).then(function (jwk) { } ``` +#### EC PEM to JWK CLI + +```bash +# Convert SEC1, PKCS8, SPKI, SSH to JWK +# eckles [keyfile] +eckles node_modules/eckles/fixtures/privkey-ec-p256.sec1.pem +eckles node_modules/eckles/fixtures/privkey-ec-p384.pkcs8.pem +eckles node_modules/eckles/fixtures/pub-ec-p256.spki.pem +eckles node_modules/eckles/fixtures/pub-ec-p384.ssh.pub +``` + ## JWK-to-PEM * [x] SEC1/X9.62, PKCS#8, SPKI/PKIX @@ -48,10 +116,10 @@ eckles.import({ pem: pem }).then(function (jwk) { * [x] SSH (RFC4716), (RFC 4716/SSH2) ```js -var eckles = require('eckles'); +var Eckles = require('eckles'); var jwk = require('eckles/fixtures/privkey-ec-p256.jwk.json'); -eckles.export({ jwk: jwk }).then(function (pem) { +Eckles.export({ jwk: jwk }).then(function (pem) { // PEM in SEC1 (x9.62) format console.log(pem); }); @@ -65,6 +133,17 @@ yZe7CnFsqeDcpnPbubP6cpYiVcnevNIYyg== -----END EC PRIVATE KEY----- ``` +#### EC PEM to JWK CLI + +```bash +# Convert JWK to SEC1, PKCS8, SPKI, SSH +# eckles [keyfile] [format] +eckles node_modules/eckles/fixtures/privkey-ec-p256.jwk.json sec1 +eckles node_modules/eckles/fixtures/privkey-ec-p384.jwk.json pkcs8 +eckles node_modules/eckles/fixtures/pub-ec-p256.jwk.json spki +eckles node_modules/eckles/fixtures/pub-ec-p384.jwk.json ssh +``` + ### Advanced Options `format: 'pkcs8'`: @@ -73,7 +152,7 @@ The default output format is `sec1`/`x9.62` (EC-specific format) is used for pri Use `format: 'pkcs8'` to output in PKCS#8 format instead. ```js -eckles.export({ jwk: jwk, format: 'pkcs8' }).then(function (pem) { +Eckles.export({ jwk: jwk, format: 'pkcs8' }).then(function (pem) { // PEM in PKCS#8 format console.log(pem); }); @@ -97,7 +176,7 @@ To get the same format as you would get with `ssh-keygen`, pass `ssh` as the format option: ```js -eckles.export({ jwk: jwk, format: 'ssh' }).then(function (pub) { +Eckles.export({ jwk: jwk, format: 'ssh' }).then(function (pub) { // Special SSH2 Public Key format (RFC 4716) console.log(pub); }); @@ -114,7 +193,7 @@ If a private key is used as input, a private key will be output. If you'd like to output a public key instead you can pass `public: true` or `format: 'spki'`. ```js -eckles.export({ jwk: jwk, public: true }).then(function (pem) { +Eckles.export({ jwk: jwk, public: true }).then(function (pem) { // PEM in SPKI/PKIX format console.log(pem); }); @@ -160,7 +239,7 @@ Goals of this project Legal ----- -Licensed MPL-2.0 - +[Eckles.js](https://git.coolaj86.com/coolaj86/eckles.js) | +MPL-2.0 | [Terms of Use](https://therootcompany.com/legal/#terms) | [Privacy Policy](https://therootcompany.com/legal/#privacy) diff --git a/bin/eckles.js b/bin/eckles.js index 079dfdb..07b5658 100755 --- a/bin/eckles.js +++ b/bin/eckles.js @@ -2,11 +2,35 @@ 'use strict'; var fs = require('fs'); -var eckles = require('../index.js'); +var Eckles = require('../index.js'); var infile = process.argv[2]; var format = process.argv[3]; +if (!infile) { + infile = 'jwk'; +} + +if (-1 !== [ 'jwk', 'pem', 'json', 'der', 'sec1', 'pkcs8', 'spki', 'ssh' ].indexOf(infile)) { + console.log("Generating new key..."); + Eckles.generate({ + format: infile + , namedCurve: format === 'P-384' ? 'P-384' : 'P-256' + , encoding: format === 'der' ? 'der' : 'pem' + }).then(function (key) { + if ('der' === infile || 'der' === format) { + key.private = key.private.toString('binary'); + key.public = key.public.toString('binary'); + } + console.log(key.private); + console.log(key.public); + }).catch(function (err) { + console.error(err); + process.exit(1); + }); + return; +} + var key = fs.readFileSync(infile, 'ascii'); try { @@ -17,14 +41,14 @@ try { if ('string' === typeof key) { var pub = (-1 !== [ 'public', 'spki', 'pkix' ].indexOf(format)); - eckles.import({ pem: key, public: (pub || format) }).then(function (jwk) { + Eckles.import({ pem: key, public: (pub || format) }).then(function (jwk) { console.log(JSON.stringify(jwk, null, 2)); }).catch(function (err) { console.error(err); process.exit(1); }); } else { - eckles.export({ jwk: key, format: format }).then(function (pem) { + Eckles.export({ jwk: key, format: format }).then(function (pem) { console.log(pem); }).catch(function (err) { console.error(err); diff --git a/lib/eckles.js b/lib/eckles.js index d413a94..40511ad 100644 --- a/lib/eckles.js +++ b/lib/eckles.js @@ -1,9 +1,10 @@ 'use strict'; -var ASN1; var EC = module.exports; -var Hex = {}; -var PEM = {}; + +var Enc = require('./encoding.js'); +var ASN1; +var PEM = require('./pem.js'); // 1.2.840.10045.3.1.7 // prime256v1 (ANSI X9.62 named elliptic curve) @@ -27,104 +28,6 @@ var SSH_EC_P384 = '00000013 65 63 64 73 61 2d 73 68 61 32 2d 6e 69 73 74 70 33 3 // The one good thing that came from the b***kchain hysteria: good EC documentation // https://davidederosa.com/basic-blockchain-programming/elliptic-curve-keys/ -PEM._toUrlSafeBase64 = function (u8) { - //console.log('Len:', u8.byteLength); - return Buffer.from(u8).toString('base64') - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -}; - -PEM.parseBlock = function pemToDer(pem) { - var typ; - var pub; - var crv; - var der = Buffer.from(pem.split(/\n/).filter(function (line, i) { - if (0 === i) { - if (/ PUBLIC /.test(line)) { - pub = true; - } else if (/ PRIVATE /.test(line)) { - pub = false; - } - if (/ EC/.test(line)) { - typ = 'EC'; - } - } - return !/---/.test(line); - }).join(''), 'base64'); - - if (!typ || 'EC' === typ) { - var hex = toHex(der); - if (-1 !== hex.indexOf(OBJ_ID_EC)) { - typ = 'EC'; - crv = 'P-256'; - } else if (-1 !== hex.indexOf(OBJ_ID_EC_384)) { - typ = 'EC'; - crv = 'P-384'; - } else { - // TODO support P-384 as well (but probably nothing else) - console.warn("unsupported ec curve"); - } - } - - return { kty: typ, pub: pub, der: der, crv: crv }; -}; - -PEM.packBlock = function (opts) { - // TODO allow for headers? - return '-----BEGIN ' + opts.type + '-----\n' - + toBase64(opts.bytes).match(/.{1,64}/g).join('\n') + '\n' - + '-----END ' + opts.type + '-----' - ; -}; - -function toHex(ab) { - var hex = []; - var u8 = new Uint8Array(ab); - var size = u8.byteLength; - var i; - var h; - for (i = 0; i < size; i += 1) { - h = u8[i].toString(16); - if (2 === h.length) { - hex.push(h); - } else { - hex.push('0' + h); - } - } - return hex.join('').replace(/\s+/g, '').toLowerCase(); -} -Hex.fromAB = toHex; - -Hex.fromInt = function numToHex(d) { - d = d.toString(16); - if (d.length % 2) { - return '0' + d; - } - return d; -}; -Hex.toUint8 = function (hex) { - var buf = Buffer.from(hex, 'hex'); - var ab = buf.buffer; - return new Uint8Array(ab.slice(buf.offset, buf.offset + buf.byteLength)); -}; - -function toBase64(u8) { - return Buffer.from(u8).toString('base64'); -} - -function urlBase64ToBase64(ub64) { - var r = ub64 % 4; - if (2 === r) { - ub64 += '=='; - } else if (3 === r) { - ub64 += '='; - } - return ub64.replace(/-/g, '+').replace(/_/g, '/'); -} -function base64ToUint8(b64) { - var buf = Buffer.from(b64, 'base64'); - return new Uint8Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)); -} - EC.parseSec1 = function parseEcOnlyPrivkey(u8, jwk) { var index = 7; var len = 32; @@ -156,12 +59,12 @@ EC.parseSec1 = function parseEcOnlyPrivkey(u8, jwk) { return { kty: jwk.kty , crv: jwk.crv - , d: PEM._toUrlSafeBase64(d) - //, dh: toHex(d) - , x: PEM._toUrlSafeBase64(x) - //, xh: toHex(x) - , y: PEM._toUrlSafeBase64(y) - //, yh: toHex(y) + , d: Enc.bufToUrlBase64(d) + //, dh: Enc.bufToHex(d) + , x: Enc.bufToUrlBase64(x) + //, xh: Enc.bufToHex(x) + , y: Enc.bufToUrlBase64(y) + //, yh: Enc.bufToHex(y) }; }; @@ -193,12 +96,12 @@ EC.parsePkcs8 = function parseEcPkcs8(u8, jwk) { return { kty: jwk.kty , crv: jwk.crv - , d: PEM._toUrlSafeBase64(d) - //, dh: toHex(d) - , x: PEM._toUrlSafeBase64(x) - //, xh: toHex(x) - , y: PEM._toUrlSafeBase64(y) - //, yh: toHex(y) + , d: Enc.bufToUrlBase64(d) + //, dh: Enc.bufToHex(d) + , x: Enc.bufToUrlBase64(x) + //, xh: Enc.bufToHex(x) + , y: Enc.bufToUrlBase64(y) + //, yh: Enc.bufToHex(y) }; }; @@ -225,10 +128,10 @@ EC.parseSpki = function parsePem(u8, jwk) { return { kty: jwk.kty , crv: jwk.crv - , x: PEM._toUrlSafeBase64(x) - //, xh: toHex(x) - , y: PEM._toUrlSafeBase64(y) - //, yh: toHex(y) + , x: Enc.bufToUrlBase64(x) + //, xh: Enc.bufToHex(x) + , y: Enc.bufToUrlBase64(y) + //, yh: Enc.bufToHex(y) }; }; EC.parsePkix = EC.parseSpki; @@ -237,7 +140,7 @@ EC.parseSsh = function (pem) { var jwk = { kty: 'EC', crv: null, x: null, y: null }; var b64 = pem.split(/\s+/g)[1]; var buf = Buffer.from(b64, 'base64'); - var hex = buf.toString('hex'); + var hex = Enc.bufToHex(buf); var index = 40; var len; if (0 === hex.indexOf(SSH_EC_P256)) { @@ -249,141 +152,224 @@ EC.parseSsh = function (pem) { } var x = buf.slice(index, index + len); var y = buf.slice(index + len, index + len + len); - jwk.x = PEM._toUrlSafeBase64(x); - jwk.y = PEM._toUrlSafeBase64(y); + jwk.x = Enc.bufToUrlBase64(x); + jwk.y = Enc.bufToUrlBase64(y); return jwk; }; /*global Promise*/ +EC.generate = function (opts) { + return Promise.resolve().then(function () { + var typ = 'ec'; + var format = opts.format; + var encoding = opts.encoding; + var priv; + var pub = 'spki'; + + if (!format) { + format = 'jwk'; + } + if (-1 !== [ 'spki', 'pkcs8', 'ssh' ].indexOf(format)) { + format = 'pkcs8'; + } + + if ('pem' === format) { + format = 'sec1'; + encoding = 'pem'; + } else if ('der' === format) { + format = 'sec1'; + encoding = 'der'; + } + + if ('jwk' === format || 'json' === format) { + format = 'jwk'; + encoding = 'json'; + } else { + priv = format; + } + + if (!encoding) { + encoding = 'pem'; + } + + if (priv) { + priv = { type: priv, format: encoding }; + pub = { type: pub, format: encoding }; + } else { + // jwk + priv = { type: 'sec1', format: 'pem' }; + pub = { type: 'spki', format: 'pem' }; + } + + return new Promise(function (resolve, reject) { + return require('crypto').generateKeyPair(typ, { + namedCurve: opts.crv || opts.namedCurve || 'P-256' + , privateKeyEncoding: priv + , publicKeyEncoding: pub + }, function (err, pubkey, privkey) { + if (err) { reject(err); } + resolve({ + private: privkey + , public: pubkey + }); + }); + }).then(function (keypair) { + if ('jwk' === format) { + return { + private: EC.importSync({ pem: keypair.private, format: priv.type }) + , public: EC.importSync({ pem: keypair.public, format: pub.type, public: true }) + }; + } + + if ('ssh' !== opts.format) { + return keypair; + } + + return { + private: keypair.private + , public: EC.exportSync({ jwk: EC.importSync({ + pem: keypair.public, format: format, public: true + }), format: opts.format, public: true }) + }; + }); + }); +}; + +EC.importSync = function importEcSync(opts) { + if (!opts || !opts.pem || 'string' !== typeof opts.pem) { + throw new Error("must pass { pem: pem } as a string"); + } + if (0 === opts.pem.indexOf('ecdsa-sha2-')) { + return EC.parseSsh(opts.pem); + } + var pem = opts.pem; + var u8 = PEM.parseBlock(pem).der; + var hex = Enc.bufToHex(u8); + var jwk = { kty: 'EC', crv: null, x: null, y: null }; + + //console.log(); + if (-1 !== hex.indexOf(OBJ_ID_EC)) { + jwk.crv = "P-256"; + + // PKCS8 + if (0x02 === u8[3] && 0x30 === u8[6] && 0x06 === u8[8]) { + //console.log("PKCS8", u8[3].toString(16), u8[6].toString(16), u8[8].toString(16)); + jwk = EC.parsePkcs8(u8, jwk); + // EC-only + } else if (0x02 === u8[2] && 0x04 === u8[5] && 0xA0 === u8[39]) { + //console.log("EC---", u8[2].toString(16), u8[5].toString(16), u8[39].toString(16)); + jwk = EC.parseSec1(u8, jwk); + // SPKI/PKIK (Public) + } else if (0x30 === u8[2] && 0x06 === u8[4] && 0x06 === u8[13]) { + //console.log("SPKI-", u8[2].toString(16), u8[4].toString(16), u8[13].toString(16)); + jwk = EC.parseSpki(u8, jwk); + // Error + } else { + //console.log("PKCS8", u8[3].toString(16), u8[6].toString(16), u8[8].toString(16)); + //console.log("EC---", u8[2].toString(16), u8[5].toString(16), u8[39].toString(16)); + //console.log("SPKI-", u8[2].toString(16), u8[4].toString(16), u8[13].toString(16)); + throw new Error("unrecognized key format"); + } + } else if (-1 !== hex.indexOf(OBJ_ID_EC_384)) { + jwk.crv = "P-384"; + + // PKCS8 + if (0x02 === u8[3] && 0x30 === u8[6] && 0x06 === u8[8]) { + //console.log("PKCS8", u8[3].toString(16), u8[6].toString(16), u8[8].toString(16)); + jwk = EC.parsePkcs8(u8, jwk); + // EC-only + } else if (0x02 === u8[3] && 0x04 === u8[6] && 0xA0 === u8[56]) { + //console.log("EC---", u8[3].toString(16), u8[6].toString(16), u8[56].toString(16)); + jwk = EC.parseSec1(u8, jwk); + // SPKI/PKIK (Public) + } else if (0x30 === u8[2] && 0x06 === u8[4] && 0x06 === u8[13]) { + //console.log("SPKI-", u8[2].toString(16), u8[4].toString(16), u8[13].toString(16)); + jwk = EC.parseSpki(u8, jwk); + // Error + } else { + //console.log("PKCS8", u8[3].toString(16), u8[6].toString(16), u8[8].toString(16)); + //console.log("EC---", u8[3].toString(16), u8[6].toString(16), u8[56].toString(16)); + //console.log("SPKI-", u8[2].toString(16), u8[4].toString(16), u8[13].toString(16)); + throw new Error("unrecognized key format"); + } + } else { + throw new Error("Supported key types are P-256 and P-384"); + } + if (opts.public) { + if (true !== opts.public) { + throw new Error("options.public must be either `true` or `false` not (" + + typeof opts.public + ") '" + opts.public + "'"); + } + delete jwk.d; + } + return jwk; +}; EC.parse = function parseEc(opts) { return Promise.resolve().then(function () { - if (!opts || !opts.pem || 'string' !== typeof opts.pem) { - throw new Error("must pass { pem: pem } as a string"); - } - if (0 === opts.pem.indexOf('ecdsa-sha2-')) { - return EC.parseSsh(opts.pem); - } - var pem = opts.pem; - var u8 = PEM.parseBlock(pem).der; - var hex = toHex(u8); - var jwk = { kty: 'EC', crv: null, x: null, y: null }; - - //console.log(); - if (-1 !== hex.indexOf(OBJ_ID_EC)) { - jwk.crv = "P-256"; - - // PKCS8 - if (0x02 === u8[3] && 0x30 === u8[6] && 0x06 === u8[8]) { - //console.log("PKCS8", u8[3].toString(16), u8[6].toString(16), u8[8].toString(16)); - jwk = EC.parsePkcs8(u8, jwk); - // EC-only - } else if (0x02 === u8[2] && 0x04 === u8[5] && 0xA0 === u8[39]) { - //console.log("EC---", u8[2].toString(16), u8[5].toString(16), u8[39].toString(16)); - jwk = EC.parseSec1(u8, jwk); - // SPKI/PKIK (Public) - } else if (0x30 === u8[2] && 0x06 === u8[4] && 0x06 === u8[13]) { - //console.log("SPKI-", u8[2].toString(16), u8[4].toString(16), u8[13].toString(16)); - jwk = EC.parseSpki(u8, jwk); - // Error - } else { - //console.log("PKCS8", u8[3].toString(16), u8[6].toString(16), u8[8].toString(16)); - //console.log("EC---", u8[2].toString(16), u8[5].toString(16), u8[39].toString(16)); - //console.log("SPKI-", u8[2].toString(16), u8[4].toString(16), u8[13].toString(16)); - throw new Error("unrecognized key format"); - } - } else if (-1 !== hex.indexOf(OBJ_ID_EC_384)) { - jwk.crv = "P-384"; - - // PKCS8 - if (0x02 === u8[3] && 0x30 === u8[6] && 0x06 === u8[8]) { - //console.log("PKCS8", u8[3].toString(16), u8[6].toString(16), u8[8].toString(16)); - jwk = EC.parsePkcs8(u8, jwk); - // EC-only - } else if (0x02 === u8[3] && 0x04 === u8[6] && 0xA0 === u8[56]) { - //console.log("EC---", u8[3].toString(16), u8[6].toString(16), u8[56].toString(16)); - jwk = EC.parseSec1(u8, jwk); - // SPKI/PKIK (Public) - } else if (0x30 === u8[2] && 0x06 === u8[4] && 0x06 === u8[13]) { - //console.log("SPKI-", u8[2].toString(16), u8[4].toString(16), u8[13].toString(16)); - jwk = EC.parseSpki(u8, jwk); - // Error - } else { - //console.log("PKCS8", u8[3].toString(16), u8[6].toString(16), u8[8].toString(16)); - //console.log("EC---", u8[3].toString(16), u8[6].toString(16), u8[56].toString(16)); - //console.log("SPKI-", u8[2].toString(16), u8[4].toString(16), u8[13].toString(16)); - throw new Error("unrecognized key format"); - } - } else { - throw new Error("Supported key types are P-256 and P-384"); - } - if (opts.public) { - if (true !== opts.public) { - throw new Error("options.public must be either `true` or `false` not (" - + typeof opts.public + ") '" + opts.public + "'"); - } - delete jwk.d; - } - return jwk; + return EC.importSync(opts); }); }; EC.toJwk = EC.import = EC.parse; +EC.exportSync = function (opts) { + if (!opts || !opts.jwk || 'object' !== typeof opts.jwk) { + throw new Error("must pass { jwk: jwk } as a JSON object"); + } + var jwk = JSON.parse(JSON.stringify(opts.jwk)); + var format = opts.format; + if (opts.public || -1 !== [ 'spki', 'pkix', 'ssh', 'rfc4716' ].indexOf(format)) { + jwk.d = null; + } + if ('EC' !== jwk.kty) { + throw new Error("options.jwk.kty must be 'EC' for EC keys"); + } + if (!jwk.d) { + if (!format || -1 !== [ 'spki', 'pkix' ].indexOf(format)) { + format = 'spki'; + } else if (-1 !== [ 'ssh', 'rfc4716' ].indexOf(format)) { + format = 'ssh'; + } else { + throw new Error("options.format must be 'spki' or 'ssh' for public EC keys, not (" + + typeof format + ") " + format); + } + } else { + if (!format || 'sec1' === format) { + format = 'sec1'; + } else if ('pkcs8' !== format) { + throw new Error("options.format must be 'sec1' or 'pkcs8' for private EC keys"); + } + } + if (-1 === [ 'P-256', 'P-384' ].indexOf(jwk.crv)) { + throw new Error("options.jwk.crv must be either P-256 or P-384 for EC keys"); + } + if (!jwk.y) { + throw new Error("options.jwk.y must be a urlsafe base64-encoded either P-256 or P-384"); + } + + if ('sec1' === format) { + return PEM.packBlock({ type: "EC PRIVATE KEY", bytes: EC.packSec1(jwk) }); + } else if ('pkcs8' === format) { + return PEM.packBlock({ type: "PRIVATE KEY", bytes: EC.packPkcs8(jwk) }); + } else if (-1 !== [ 'spki', 'pkix' ].indexOf(format)) { + return PEM.packBlock({ type: "PUBLIC KEY", bytes: EC.packSpki(jwk) }); + } else if (-1 !== [ 'ssh', 'rfc4716' ].indexOf(format)) { + return EC.packSsh(jwk); + } else { + throw new Error("Sanity Error: reached unreachable code block with format: " + format); + } +}; EC.pack = function (opts) { return Promise.resolve().then(function () { - if (!opts || !opts.jwk || 'object' !== typeof opts.jwk) { - throw new Error("must pass { jwk: jwk }"); - } - var jwk = JSON.parse(JSON.stringify(opts.jwk)); - var format = opts.format; - if (opts.public || -1 !== [ 'spki', 'pkix', 'ssh', 'rfc4716' ].indexOf(format)) { - jwk.d = null; - } - if ('EC' !== jwk.kty) { - throw new Error("options.jwk.kty must be 'EC' for EC keys"); - } - if (!jwk.d) { - if (!format || -1 !== [ 'spki', 'pkix' ].indexOf(format)) { - format = 'spki'; - } else if (-1 !== [ 'ssh', 'rfc4716' ].indexOf(format)) { - format = 'ssh'; - } else { - throw new Error("options.format must be 'spki' or 'ssh' for public EC keys, not (" - + typeof format + ") " + format); - } - } else { - if (!format || 'sec1' === format) { - format = 'sec1'; - } else if ('pkcs8' !== format) { - throw new Error("options.format must be 'sec1' or 'pkcs8' for private EC keys"); - } - } - if (-1 === [ 'P-256', 'P-384' ].indexOf(jwk.crv)) { - throw new Error("options.jwk.crv must be either P-256 or P-384 for EC keys"); - } - if (!jwk.y) { - throw new Error("options.jwk.y must be a urlsafe base64-encoded either P-256 or P-384"); - } - - if ('sec1' === format) { - return PEM.packBlock({ type: "EC PRIVATE KEY", bytes: EC.packSec1(jwk) }); - } else if ('pkcs8' === format) { - return PEM.packBlock({ type: "PRIVATE KEY", bytes: EC.packPkcs8(jwk) }); - } else if (-1 !== [ 'spki', 'pkix' ].indexOf(format)) { - return PEM.packBlock({ type: "PUBLIC KEY", bytes: EC.packSpki(jwk) }); - } else if (-1 !== [ 'ssh', 'rfc4716' ].indexOf(format)) { - return EC.packSsh(jwk); - } else { - throw new Error("Sanity Error: reached unreachable code block with format: " + format); - } + return EC.exportSync(opts); }); }; EC.packSec1 = function (jwk) { - var d = toHex(base64ToUint8(urlBase64ToBase64(jwk.d))); - var x = toHex(base64ToUint8(urlBase64ToBase64(jwk.x))); - var y = toHex(base64ToUint8(urlBase64ToBase64(jwk.y))); + var d = Enc.base64ToHex(jwk.d); + var x = Enc.base64ToHex(jwk.x); + var y = Enc.base64ToHex(jwk.y); var objId = ('P-256' === jwk.crv) ? OBJ_ID_EC : OBJ_ID_EC_384; - return Hex.toUint8( + return Enc.hexToUint8( ASN1('30' , ASN1.UInt('01') , ASN1('04', d) @@ -392,11 +378,11 @@ EC.packSec1 = function (jwk) { ); }; EC.packPkcs8 = function (jwk) { - var d = toHex(base64ToUint8(urlBase64ToBase64(jwk.d))); - var x = toHex(base64ToUint8(urlBase64ToBase64(jwk.x))); - var y = toHex(base64ToUint8(urlBase64ToBase64(jwk.y))); + var d = Enc.base64ToHex(jwk.d); + var x = Enc.base64ToHex(jwk.x); + var y = Enc.base64ToHex(jwk.y); var objId = ('P-256' === jwk.crv) ? OBJ_ID_EC : OBJ_ID_EC_384; - return Hex.toUint8( + return Enc.hexToUint8( ASN1('30' , ASN1.UInt('00') , ASN1('30' @@ -411,10 +397,10 @@ EC.packPkcs8 = function (jwk) { ); }; EC.packSpki = function (jwk) { - var x = toHex(base64ToUint8(urlBase64ToBase64(jwk.x))); - var y = toHex(base64ToUint8(urlBase64ToBase64(jwk.y))); + var x = Enc.base64ToHex(jwk.x); + var y = Enc.base64ToHex(jwk.y); var objId = ('P-256' === jwk.crv) ? OBJ_ID_EC : OBJ_ID_EC_384; - return Hex.toUint8( + return Enc.hexToUint8( ASN1('30' , ASN1('30' , OBJ_ID_EC_PUB @@ -435,15 +421,15 @@ EC.packSsh = function (jwk) { a = '33 38 34'; b = '61'; } - var x = toHex(base64ToUint8(urlBase64ToBase64(jwk.x))); - var y = toHex(base64ToUint8(urlBase64ToBase64(jwk.y))); - var ssh = Hex.toUint8( + var x = Enc.base64ToHex(jwk.x); + var y = Enc.base64ToHex(jwk.y); + var ssh = Enc.hexToUint8( ('00 00 00 13 65 63 64 73 61 2d 73 68 61 32 2d 6e 69 73 74 70' + a + '00 00 00 08 6e 69 73 74 70' + a + '00 00 00' + b + '04' + x + y).replace(/\s+/g, '').toLowerCase() ); - return typ + ' ' + toBase64(ssh) + ' ' + comment; + return typ + ' ' + Enc.bufToBase64(ssh) + ' ' + comment; }; // @@ -481,8 +467,8 @@ ASN1 = function ASN1(/*type, hexstrings...*/) { } } - if (lenlen) { hex += Hex.fromInt(0x80 + lenlen); } - return hex + Hex.fromInt(str.length/2) + str; + if (lenlen) { hex += Enc.numToHex(0x80 + lenlen); } + return hex + Enc.numToHex(str.length/2) + str; }; // The Integer type has some special rules diff --git a/lib/encoding.js b/lib/encoding.js new file mode 100644 index 0000000..af6c0b8 --- /dev/null +++ b/lib/encoding.js @@ -0,0 +1,42 @@ +'use strict'; + +var Enc = module.exports; + +Enc.base64ToBuf = function base64ToBuf(str) { + // node handles both base64 and urlBase64 equally + return Buffer.from(str, 'base64'); +}; + +Enc.base64ToHex = function base64ToHex(b64) { + return Enc.bufToHex(Enc.base64ToBuf(b64)); +}; + +Enc.bufToBase64 = function toHex(u8) { + // Ensure a node buffer, even if TypedArray + return Buffer.from(u8).toString('base64'); +}; + +Enc.bufToHex = function bufToHex(u8) { + // Ensure a node buffer, even if TypedArray + return Buffer.from(u8).toString('hex'); +}; + +Enc.bufToUrlBase64 = function bufToUrlBase64(u8) { + return Enc.bufToBase64(u8) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; + +Enc.hexToUint8 = function (hex) { + // TODO: I don't remember why I chose Uint8Array over Buffer... + var buf = Buffer.from(hex, 'hex'); + var ab = buf.buffer.slice(buf.offset, buf.offset + buf.byteLength); + return new Uint8Array(ab); +}; + +Enc.numToHex = function numToHex(d) { + d = d.toString(16); + if (d.length % 2) { + return '0' + d; + } + return d; +}; diff --git a/lib/pem.js b/lib/pem.js new file mode 100644 index 0000000..fdbe3dc --- /dev/null +++ b/lib/pem.js @@ -0,0 +1,56 @@ +'use strict'; + +var PEM = module.exports; +var Enc = require('./encoding.js'); + +// TODO move object id hinting to x509.js + +// 1.2.840.10045.3.1.7 +// prime256v1 (ANSI X9.62 named elliptic curve) +var OBJ_ID_EC = '06 08 2A8648CE3D030107'.replace(/\s+/g, '').toLowerCase(); +// 1.3.132.0.34 +// secp384r1 (SECG (Certicom) named elliptic curve) +var OBJ_ID_EC_384 = '06 05 2B81040022'.replace(/\s+/g, '').toLowerCase(); + +PEM.parseBlock = function pemToDer(pem) { + var typ; + var pub; + var crv; + var der = Buffer.from(pem.split(/\n/).filter(function (line, i) { + if (0 === i) { + if (/ PUBLIC /.test(line)) { + pub = true; + } else if (/ PRIVATE /.test(line)) { + pub = false; + } + if (/ EC/.test(line)) { + typ = 'EC'; + } + } + return !/---/.test(line); + }).join(''), 'base64'); + + if (!typ || 'EC' === typ) { + var hex = Enc.bufToHex(der); + if (-1 !== hex.indexOf(OBJ_ID_EC)) { + typ = 'EC'; + crv = 'P-256'; + } else if (-1 !== hex.indexOf(OBJ_ID_EC_384)) { + typ = 'EC'; + crv = 'P-384'; + } else { + // TODO support P-384 as well (but probably nothing else) + console.warn("unsupported ec curve"); + } + } + + return { kty: typ, pub: pub, der: der, crv: crv }; +}; + +PEM.packBlock = function (opts) { + // TODO allow for headers? + return '-----BEGIN ' + opts.type + '-----\n' + + Enc.bufToBase64(opts.bytes).match(/.{1,64}/g).join('\n') + '\n' + + '-----END ' + opts.type + '-----' + ; +}; diff --git a/test.sh b/test.sh index 116409f..a72b887 100644 --- a/test.sh +++ b/test.sh @@ -1,77 +1,87 @@ #/bin/bash set -e -echo "" echo "" echo "Testing PEM-to-JWK P-256" -echo "" -node bin/eckles.js fixtures/privkey-ec-p256.sec1.pem | tee fixtures/privkey-ec-p256.jwk.2 +node bin/eckles.js fixtures/privkey-ec-p256.sec1.pem \ + > fixtures/privkey-ec-p256.jwk.2 diff fixtures/privkey-ec-p256.jwk.json fixtures/privkey-ec-p256.jwk.2 -node bin/eckles.js fixtures/privkey-ec-p256.pkcs8.pem | tee fixtures/privkey-ec-p256.jwk.2 +node bin/eckles.js fixtures/privkey-ec-p256.pkcs8.pem \ + > fixtures/privkey-ec-p256.jwk.2 diff fixtures/privkey-ec-p256.jwk.json fixtures/privkey-ec-p256.jwk.2 -node bin/eckles.js fixtures/pub-ec-p256.spki.pem | tee fixtures/pub-ec-p256.jwk.2 +node bin/eckles.js fixtures/pub-ec-p256.spki.pem \ + > fixtures/pub-ec-p256.jwk.2 diff fixtures/pub-ec-p256.jwk.json fixtures/pub-ec-p256.jwk.2 # -node bin/eckles.js fixtures/pub-ec-p256.ssh.pub | tee fixtures/pub-ec-p256.jwk.2 +node bin/eckles.js fixtures/pub-ec-p256.ssh.pub \ + > fixtures/pub-ec-p256.jwk.2 diff fixtures/pub-ec-p256.jwk.2 fixtures/pub-ec-p256.jwk.2 +echo "PASS" -echo "" echo "" echo "Testing PEM-to-JWK P-384" -echo "" -node bin/eckles.js fixtures/privkey-ec-p384.sec1.pem | tee fixtures/privkey-ec-p384.jwk.2 +node bin/eckles.js fixtures/privkey-ec-p384.sec1.pem \ + > fixtures/privkey-ec-p384.jwk.2 diff fixtures/privkey-ec-p384.jwk.json fixtures/privkey-ec-p384.jwk.2 -node bin/eckles.js fixtures/privkey-ec-p384.pkcs8.pem | tee fixtures/privkey-ec-p384.jwk.2.2 +node bin/eckles.js fixtures/privkey-ec-p384.pkcs8.pem \ + > fixtures/privkey-ec-p384.jwk.2.2 diff fixtures/privkey-ec-p384.jwk.json fixtures/privkey-ec-p384.jwk.2.2 -node bin/eckles.js fixtures/pub-ec-p384.spki.pem | tee fixtures/pub-ec-p384.jwk.2 +node bin/eckles.js fixtures/pub-ec-p384.spki.pem \ + > fixtures/pub-ec-p384.jwk.2 diff fixtures/pub-ec-p384.jwk.json fixtures/pub-ec-p384.jwk.2 # -node bin/eckles.js fixtures/pub-ec-p384.ssh.pub | tee fixtures/pub-ec-p384.jwk.2 +node bin/eckles.js fixtures/pub-ec-p384.ssh.pub \ + > fixtures/pub-ec-p384.jwk.2 diff fixtures/pub-ec-p384.jwk.2 fixtures/pub-ec-p384.jwk.2 +echo "PASS" -echo "" echo "" echo "Testing JWK-to-PEM P-256" -echo "" -node bin/eckles.js fixtures/privkey-ec-p256.jwk.json sec1 | tee fixtures/privkey-ec-p256.sec1.pem.2 +node bin/eckles.js fixtures/privkey-ec-p256.jwk.json sec1 \ + > fixtures/privkey-ec-p256.sec1.pem.2 diff fixtures/privkey-ec-p256.sec1.pem fixtures/privkey-ec-p256.sec1.pem.2 # -node bin/eckles.js fixtures/privkey-ec-p256.jwk.json pkcs8 | tee fixtures/privkey-ec-p256.pkcs8.pem.2 +node bin/eckles.js fixtures/privkey-ec-p256.jwk.json pkcs8 \ + > fixtures/privkey-ec-p256.pkcs8.pem.2 diff fixtures/privkey-ec-p256.pkcs8.pem fixtures/privkey-ec-p256.pkcs8.pem.2 # -node bin/eckles.js fixtures/pub-ec-p256.jwk.json spki | tee fixtures/pub-ec-p256.spki.pem.2 +node bin/eckles.js fixtures/pub-ec-p256.jwk.json spki \ + > fixtures/pub-ec-p256.spki.pem.2 diff fixtures/pub-ec-p256.spki.pem fixtures/pub-ec-p256.spki.pem.2 # ssh-keygen -f fixtures/pub-ec-p256.spki.pem -i -mPKCS8 > fixtures/pub-ec-p256.ssh.pub -node bin/eckles.js fixtures/pub-ec-p256.jwk.json ssh | tee fixtures/pub-ec-p256.ssh.pub.2 +node bin/eckles.js fixtures/pub-ec-p256.jwk.json ssh \ + > fixtures/pub-ec-p256.ssh.pub.2 diff fixtures/pub-ec-p256.ssh.pub fixtures/pub-ec-p256.ssh.pub.2 +echo "PASS" -echo "" echo "" echo "Testing JWK-to-PEM P-384" -echo "" -node bin/eckles.js fixtures/privkey-ec-p384.jwk.json sec1 | tee fixtures/privkey-ec-p384.sec1.pem.2 +node bin/eckles.js fixtures/privkey-ec-p384.jwk.json sec1 \ + > fixtures/privkey-ec-p384.sec1.pem.2 diff fixtures/privkey-ec-p384.sec1.pem fixtures/privkey-ec-p384.sec1.pem.2 # -node bin/eckles.js fixtures/privkey-ec-p384.jwk.json pkcs8 | tee fixtures/privkey-ec-p384.pkcs8.pem.2 +node bin/eckles.js fixtures/privkey-ec-p384.jwk.json pkcs8 \ + > fixtures/privkey-ec-p384.pkcs8.pem.2 diff fixtures/privkey-ec-p384.pkcs8.pem fixtures/privkey-ec-p384.pkcs8.pem.2 # -node bin/eckles.js fixtures/pub-ec-p384.jwk.json spki | tee fixtures/pub-ec-p384.spki.pem.2 +node bin/eckles.js fixtures/pub-ec-p384.jwk.json spki \ + > fixtures/pub-ec-p384.spki.pem.2 diff fixtures/pub-ec-p384.spki.pem fixtures/pub-ec-p384.spki.pem.2 # ssh-keygen -f fixtures/pub-ec-p384.spki.pem -i -mPKCS8 > fixtures/pub-ec-p384.ssh.pub -node bin/eckles.js fixtures/pub-ec-p384.jwk.json ssh | tee fixtures/pub-ec-p384.ssh.pub.2 +node bin/eckles.js fixtures/pub-ec-p384.jwk.json ssh \ + > fixtures/pub-ec-p384.ssh.pub.2 diff fixtures/pub-ec-p384.ssh.pub fixtures/pub-ec-p384.ssh.pub.2 +echo "PASS" rm fixtures/*.2 -echo "" echo "" echo "Testing freshly generated keypair" -echo "" # Generate EC P-256 Keypair openssl ecparam -genkey -name prime256v1 -noout -out ./privkey-ec-p256.sec1.pem # Export Public-only EC Key (as SPKI) @@ -97,7 +107,16 @@ diff ./pub-ec-p256.spki.pem ./pub-ec-p256.spki.pem.2 node bin/eckles.js ./pub-ec-p256.ssh.pub > ./pub-ec-p256.jwk.json node bin/eckles.js ./pub-ec-p256.jwk.json ssh > ./pub-ec-p256.ssh.pub.2 diff ./pub-ec-p256.ssh.pub ./pub-ec-p256.ssh.pub.2 +echo "PASS" +echo "" +echo "Testing key generation" +node bin/eckles.js jwk > /dev/null +node bin/eckles.js jwk P-384 > /dev/null +node bin/eckles.js sec1 > /dev/null +node bin/eckles.js pkcs8 > /dev/null +node bin/eckles.js ssh #> /dev/null +echo "PASS" rm *.2 @@ -107,4 +126,5 @@ echo "" echo "PASSED:" echo "• All inputs produced valid outputs" echo "• All outputs matched known-good values" +echo "• Generated keys in each format (sec1, pkcs8, jwk, ssh)" echo ""