diff --git a/README.md b/README.md index 4da4da6..631c738 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,46 @@ -# csr.js +# @root/csr -Lightweight, Zero-Dependency CSR (Certificate Signing Request) generator and parser for Node.js and Browsers \ No newline at end of file +Lightweight, Zero-Dependency CSR (Certificate Signing Request) generator and parser for Node.js and Browsers + +# Usage + +```js +var CSR = require('@root/csr'); +var PEM = require('@root/pem/packer'); + +CSR.csr({ + jwk: jwk, + domains: ['example.com', '*.example.com', 'foo.bar.example.com'], + encoding: 'pem' +}).then(function(der) { + var csr = PEM.packBlock({ type: 'CERTIFICATE REQUEST', bytes: der }); + console.log(csr); +}); +``` + +```txt +-----BEGIN CERTIFICATE REQUEST----- +MIIBHjCBxQIBADAWMRQwEgYDVQQDDAtleGFtcGxlLmNvbTBZMBMGByqGSM49AgEG +CCqGSM49AwEHA0IABFL897BlwE6Tmco/r7LpwVL2BdDx12zZr+BnA/0/PjkI0lsu +013u1+X5fe6vKnOIjcb5obaFnSQixuMGu3qcVnmgTTBLBgkqhkiG9w0BCQ4xPjA8 +MDoGA1UdEQQzMDGCC2V4YW1wbGUuY29tgg0qLmV4YW1wbGUuY29tghNmb28uYmFy +LmV4YW1wbGUuY29tMAoGCCqGSM49BAMCA0gAMEUCIADRCWsMYBjm70Hqi08QrOcR +Gcz8uJTe7vZwqOGtykWiAiEA1FTbMskZR9w2ugFWXkWfBdb1W6cD2v6nK+J0wj2r +Q48= +-----END CERTIFICATE REQUEST----- +``` + +# Advanced Usage + +Create an unsigned request + +``` +var CSR = require('@root/csr'); + +// Note: this requires the public key to embed it in the request +var hex = CSR.request({ + jwk: jwk, + domains: ['example.com', '*.example.com', 'foo.bar.example.com'], + encoding: 'hex' +}) +``` diff --git a/csr.js b/csr.js index 8a015e6..165951d 100644 --- a/csr.js +++ b/csr.js @@ -1,3 +1,336 @@ +// Copyright 2018-present AJ ONeal. All rights reserved +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 'use strict'; +/*global Promise*/ -module.exports = require('@root/acme/csr'); +var Enc = require('@root/encoding'); + +var ASN1 = require('@root/asn1/packer'); // DER, actually +var Asn1 = ASN1.Any; +var BitStr = ASN1.BitStr; +var UInt = ASN1.UInt; +var Asn1Parser = require('@root/asn1/parser'); +var PEM = require('@root/pem'); +var X509 = require('@root/x509'); +// TODO @root/keypairs/sign +var Keypairs = require('@root/keypairs'); + +// TODO find a way that the prior node-ish way of `module.exports = function () {}` isn't broken +var CSR = module.exports; + +// { jwk, domains } +CSR.csr = function(opts) { + // We're using a Promise here to be compatible with the browser version + // which will probably use the webcrypto API for some of the conversions + return CSR._prepare(opts).then(function(opts) { + return CSR.create(opts).then(function(bytes) { + return CSR._encode(opts, bytes); + }); + }); +}; + +CSR._prepare = function(opts) { + return Promise.resolve().then(function() { + opts = JSON.parse(JSON.stringify(opts)); + + // We do a bit of extra error checking for user convenience + if (!opts) { + throw new Error( + 'You must pass options with key and domains to rsacsr' + ); + } + if (!Array.isArray(opts.domains) || 0 === opts.domains.length) { + new Error('You must pass options.domains as a non-empty array'); + } + + // I need to check that 例.中国 is a valid domain name + if ( + !opts.domains.every(function(d) { + // allow punycode? xn-- + if ( + 'string' === typeof d /*&& /\./.test(d) && !/--/.test(d)*/ + ) { + return true; + } + }) + ) { + throw new Error('You must pass options.domains as strings'); + } + + if (opts.jwk) { + return opts; + } + if (opts.key && opts.key.kty) { + opts.jwk = opts.key; + return opts; + } + if (!opts.pem && !opts.key) { + throw new Error('You must pass options.key as a JSON web key'); + } + + return Keypairs.import({ pem: opts.pem || opts.key }).then(function( + pair + ) { + opts.jwk = pair.private; + return opts; + }); + }); +}; + +CSR._encode = function(opts, bytes) { + if ('der' === (opts.encoding || '').toLowerCase()) { + return bytes; + } + return PEM.packBlock({ + type: 'CERTIFICATE REQUEST', + bytes: bytes /* { jwk: jwk, domains: opts.domains } */ + }); +}; + +// { jwk, domains } +CSR.create = function createCsr(opts) { + var hex = CSR.request({ + jwk: opts.jwk, + domains: opts.domains, + encoding: 'hex' + }); + return CSR._sign(opts.jwk, hex).then(function(csr) { + return Enc.hexToBuf(csr); + }); +}; + +// +// EC / RSA +// +// { jwk, domains } +CSR.request = function createCsrBody(opts) { + var asn1pub; + if (/^EC/i.test(opts.jwk.kty)) { + asn1pub = X509.packCsrEcPublicKey(opts.jwk); + } else { + asn1pub = X509.packCsrRsaPublicKey(opts.jwk); + } + var hex = X509.packCsr(asn1pub, opts.domains); + if ('hex' === opts.encoding) { + return hex; + } + // der + return Enc.hexToBuf(hex); +}; + +CSR._sign = function csrEcSig(jwk, request) { + // Took some tips from https://gist.github.com/codermapuche/da4f96cdb6d5ff53b7ebc156ec46a10a + // TODO will have to convert web ECDSA signatures to PEM ECDSA signatures (but RSA should be the same) + // TODO have a consistent non-private way to sign + return Keypairs.sign( + { jwk: jwk, format: 'x509' }, + Enc.hexToBuf(request) + ).then(function(sig) { + return CSR._toDer({ + request: request, + signature: sig, + kty: jwk.kty + }); + }); +}; + +CSR._toDer = function encode(opts) { + var sty; + if (/^EC/i.test(opts.kty)) { + // 1.2.840.10045.4.3.2 ecdsaWithSHA256 (ANSI X9.62 ECDSA algorithm with SHA256) + sty = Asn1('30', Asn1('06', '2a8648ce3d040302')); + } else { + // 1.2.840.113549.1.1.11 sha256WithRSAEncryption (PKCS #1) + sty = Asn1('30', Asn1('06', '2a864886f70d01010b'), Asn1('05')); + } + return Asn1( + '30', + // The Full CSR Request Body + opts.request, + // The Signature Type + sty, + // The Signature + BitStr(Enc.bufToHex(opts.signature)) + ); +}; + +X509.packCsr = function(asn1pubkey, domains) { + return Asn1( + '30', + // Version (0) + UInt('00'), + + // 2.5.4.3 commonName (X.520 DN component) + Asn1( + '30', + Asn1( + '31', + Asn1( + '30', + Asn1('06', '550403'), + // TODO utf8 => punycode + Asn1('0c', Enc.strToHex(domains[0])) + ) + ) + ), + + // Public Key (RSA or EC) + asn1pubkey, + + // Request Body + Asn1( + 'a0', + Asn1( + '30', + // 1.2.840.113549.1.9.14 extensionRequest (PKCS #9 via CRMF) + Asn1('06', '2a864886f70d01090e'), + Asn1( + '31', + Asn1( + '30', + Asn1( + '30', + // 2.5.29.17 subjectAltName (X.509 extension) + Asn1('06', '551d11'), + Asn1( + '04', + Asn1( + '30', + domains + .map(function(d) { + // TODO utf8 => punycode + return Asn1('82', Enc.strToHex(d)); + }) + .join('') + ) + ) + ) + ) + ) + ) + ) + ); +}; + +// TODO finish this later +// we want to parse the domains, the public key, and verify the signature +CSR._info = function(der) { + // standard base64 PEM + if ('string' === typeof der && '-' === der[0]) { + der = PEM.parseBlock(der).bytes; + } + // jose urlBase64 not-PEM + if ('string' === typeof der) { + der = Enc.base64ToBuf(der); + } + // not supporting binary-encoded base64 + var c = Asn1Parser.parse({ der: der, verbose: true, json: false }); + var kty; + // A cert has 3 parts: cert, signature meta, signature + if (c.children.length !== 3) { + throw new Error( + "doesn't look like a certificate request: expected 3 parts of header" + ); + } + var sig = c.children[2]; + if (sig.children.length) { + // ASN1/X509 EC + sig = sig.children[0]; + sig = Asn1( + '30', + UInt(Enc.bufToHex(sig.children[0].value)), + UInt(Enc.bufToHex(sig.children[1].value)) + ); + sig = Enc.hexToBuf(sig); + kty = 'EC'; + } else { + // Raw RSA Sig + sig = sig.value; + kty = 'RSA'; + } + //c.children[1]; // signature type + var req = c.children[0]; + if (4 !== req.children.length) { + throw new Error( + "doesn't look like a certificate request: expected 4 parts to request" + ); + } + // 0 null + // 1 commonName / subject + var sub = Enc.bufToStr( + req.children[1].children[0].children[0].children[1].value + ); + // 3 public key (type, key) + //console.log('oid', Enc.bufToHex(req.children[2].children[0].children[0].value)); + var pub; + // TODO reuse ASN1 parser for these? + if ('EC' === kty) { + // throw away compression byte + pub = req.children[2].children[1].value.slice(1); + pub = { kty: kty, x: pub.slice(0, 32), y: pub.slice(32) }; + while (0 === pub.x[0]) { + pub.x = pub.x.slice(1); + } + while (0 === pub.y[0]) { + pub.y = pub.y.slice(1); + } + if ((pub.x.length || pub.x.byteLength) > 48) { + pub.crv = 'P-521'; + } else if ((pub.x.length || pub.x.byteLength) > 32) { + pub.crv = 'P-384'; + } else { + pub.crv = 'P-256'; + } + pub.x = Enc.bufToUrlBase64(pub.x); + pub.y = Enc.bufToUrlBase64(pub.y); + } else { + pub = req.children[2].children[1].children[0]; + pub = { + kty: kty, + n: pub.children[0].value, + e: pub.children[1].value + }; + while (0 === pub.n[0]) { + pub.n = pub.n.slice(1); + } + while (0 === pub.e[0]) { + pub.e = pub.e.slice(1); + } + pub.n = Enc.bufToUrlBase64(pub.n); + pub.e = Enc.bufToUrlBase64(pub.e); + } + // 4 extensions + var domains = req.children[3].children + .filter(function(seq) { + // 1.2.840.113549.1.9.14 extensionRequest (PKCS #9 via CRMF) + if ('2a864886f70d01090e' === Enc.bufToHex(seq.children[0].value)) { + return true; + } + }) + .map(function(seq) { + return seq.children[1].children[0].children + .filter(function(seq2) { + // subjectAltName (X.509 extension) + if ('551d11' === Enc.bufToHex(seq2.children[0].value)) { + return true; + } + }) + .map(function(seq2) { + return seq2.children[1].children[0].children.map(function( + name + ) { + // TODO utf8 => punycode + return Enc.bufToStr(name.value); + }); + })[0]; + })[0]; + + return { + subject: sub, + altnames: domains, + jwk: pub, + signature: sig + }; +}; diff --git a/package-lock.json b/package-lock.json index 380d214..fb5635a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,75 @@ { "name": "@root/csr", - "version": "1.0.0", + "version": "0.8.0", "lockfileVersion": 1, "requires": true, "dependencies": { - "acme-dns-01-digitalocean": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/acme-dns-01-digitalocean/-/acme-dns-01-digitalocean-3.0.1.tgz", - "integrity": "sha512-LUdOGluDERQWJG4CwlC9HbzUai4mtKzCz8nzpVTirXup2WwH60iRFAcd81hRGaoWbd0Bc0m6RVjN9YFkXB84yA==", - "dev": true + "@root/acme": { + "version": "3.0.0-wip.0", + "resolved": "https://registry.npmjs.org/@root/acme/-/acme-3.0.0-wip.0.tgz", + "integrity": "sha512-IwnG3ZFt1fl81O1M+FFV91b5Kpw7GYAD1jXwvOWnq9KF50AVO6+L7MUQIAFCK1q/u/weC73DCFrw/6kFN+Vi9A==", + "requires": { + "@root/csr": "^1.0.0-wip.0", + "@root/encoding": "^1.0.1" + } + }, + "@root/asn1": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@root/asn1/-/asn1-1.0.0.tgz", + "integrity": "sha512-0lfZNuOULKJDJmdIkP8V9RnbV3XaK6PAHD3swnFy4tZwtlMDzLKoM/dfNad7ut8Hu3r91wy9uK0WA/9zym5mig==", + "requires": { + "@root/encoding": "^1.0.1" + } + }, + "@root/csr": { + "version": "1.0.0-wip.0", + "resolved": "https://registry.npmjs.org/@root/csr/-/csr-1.0.0-wip.0.tgz", + "integrity": "sha512-ZrZeGgf/hvfIyMDAZXfD45rYriaZF6LJu7+l0ioPPKgLWVEUAUBkV53z7JbzlcPvXXr6/ZjECzWQ7MYQfMBUAg==", + "requires": { + "@root/acme": "^3.0.0-wip.0" + } + }, + "@root/encoding": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@root/encoding/-/encoding-1.0.1.tgz", + "integrity": "sha512-OaEub02ufoU038gy6bsNHQOjIn8nUjGiLcaRmJ40IUykneJkIW5fxDqKxQx48cszuNflYldsJLPPXCrGfHs8yQ==" + }, + "@root/keypairs": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@root/keypairs/-/keypairs-0.9.0.tgz", + "integrity": "sha512-NXE2L9Gv7r3iC4kB/gTPZE1vO9Ox/p14zDzAJ5cGpTpytbWOlWF7QoHSJbtVX4H7mRG/Hp7HR3jWdWdb2xaaXg==", + "dev": true, + "requires": { + "@root/encoding": "^1.0.1", + "@root/pem": "^1.0.4", + "@root/x509": "^0.7.2" + }, + "dependencies": { + "@root/x509": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@root/x509/-/x509-0.7.2.tgz", + "integrity": "sha512-ENq3LGYORK5NiMFHEVeNMt+fTXaC7DTS6sQXoqV+dFdfT0vmiL5cDLjaXQhaklJQq0NiwicZegzJRl1ZOTp3WQ==", + "dev": true, + "requires": { + "@root/asn1": "^1.0.0", + "@root/encoding": "^1.0.1" + } + } + } + }, + "@root/pem": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@root/pem/-/pem-1.0.4.tgz", + "integrity": "sha512-rEUDiUsHtild8GfIjFE9wXtcVxeS+ehCJQBwbQQ3IVfORKHK93CFnRtkr69R75lZFjcmKYVc+AXDB+AeRFOULA==" + }, + "@root/x509": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@root/x509/-/x509-0.7.2.tgz", + "integrity": "sha512-ENq3LGYORK5NiMFHEVeNMt+fTXaC7DTS6sQXoqV+dFdfT0vmiL5cDLjaXQhaklJQq0NiwicZegzJRl1ZOTp3WQ==", + "requires": { + "@root/asn1": "^1.0.0", + "@root/encoding": "^1.0.1" + } } } } diff --git a/package.json b/package.json index 579e0cb..fc5aebe 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,13 @@ { "name": "@root/csr", - "version": "1.0.0-wip.0", + "version": "0.8.0", "description": "Lightweight, Zero-Dependency CSR (Certificate Signing Request) generator and parser for Node.js and Browsers", "main": "csr.js", + "files": [ + "*.js", + "lib", + "dist" + ], "scripts": { "test": "node tests" }, @@ -26,6 +31,12 @@ "author": "AJ ONeal (https://coolaj86.com/)", "license": "MPL-2.0", "dependencies": { - "@root/acme": "^3.0.0-wip.0" + "@root/acme": "^3.0.0-wip.0", + "@root/asn1": "^1.0.0", + "@root/pem": "^1.0.4", + "@root/x509": "^0.7.2" + }, + "devDependencies": { + "@root/keypairs": "^0.9.0" } } diff --git a/tests/index.js b/tests/index.js new file mode 100644 index 0000000..80c00a5 --- /dev/null +++ b/tests/index.js @@ -0,0 +1,32 @@ +'use strict'; + +var Keypairs = require('@root/keypairs'); +//var CSR = require('@root/csr'); +var CSR = require('../csr.js'); +//var PEM = require('@root/pem/packer'); + +async function run() { + var pair = await Keypairs.generate(); + + var hex = CSR.request({ + jwk: pair.public, + domains: ['example.com', '*.example.com', 'foo.bar.example.com'], + encoding: 'hex' + }); + //console.log(hex); + + CSR.csr({ + jwk: pair.private, + domains: ['example.com', '*.example.com', 'foo.bar.example.com'], + encoding: 'pem' + }).then(function(csr) { + //var csr = PEM.packBlock({ type: 'CERTIFICATE REQUEST', bytes: der }); + console.log(csr); + if (!/^-----BEGIN CERTIFICATE REQUEST-----\s*MIIB/m.test(csr)) { + throw new Error("invalid CSR PEM"); + } + console.info('PASS: (if it looks right)'); + }); +} + +run();