diff --git a/bin/rsa-csr.js b/bin/rsa-csr.js index e9cbdcb..a923330 100755 --- a/bin/rsa-csr.js +++ b/bin/rsa-csr.js @@ -7,9 +7,15 @@ var rsacsr = require('../index.js'); var keyname = process.argv[2]; var domains = process.argv[3].split(/,/); -var keypem = fs.readFileSync(keyname, 'ascii'); +var key = fs.readFileSync(keyname, 'ascii'); -rsacsr({ key: keypem, domains: domains }).then(function (csr) { +try { + key = JSON.parse(key); +} catch(e) { + // ignore +} + +rsacsr({ key: key, domains: domains }).then(function (csr) { // Using error so that we can redirect stdout to file //console.error("CN=" + domains[0]); //console.error("subjectAltName=" + domains.join(',')); diff --git a/fixtures/whatever.net-www-api.csr.pem b/fixtures/whatever.net-www-api.csr.pem new file mode 100644 index 0000000..9275f2d --- /dev/null +++ b/fixtures/whatever.net-www-api.csr.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICqjCCAZICAQAwFzEVMBMGA1UEAwwMd2hhdGV2ZXIubmV0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm2ttVBxPlWw06ZmGBWVDlfjkPAJ4DgnY0TrD +wtCohHzLxGhDNzUJefLukC+xu0LBKylYojT5vTkxaOhxeSYo31syu4WhxbkTBLIC +OFcCGMob6pSQ38P8LdAIlb0pqDHxEJ9adWomjuFf0SUhN1cP7s9m8Yk9trkpEqjs +kocn2BOnTB57qAZM6+I70on0/iDZm7+jcqOPgADAmbWHhy67BXkk4yy/YzD4yOGZ +FXZcNp915/TW5bRd//AKPHUHxJasPiyEFqlNKBR2DSD+LbX5eTmzCh2ikrwTMja7 +mUdBJf2bK3By5AB0Qi49OykUCfNZeQlEz7UNNj9RGps/50+CNwIDAQABoE4wTAYJ +KoZIhvcNAQkOMT8wPTA7BgNVHREENDAyggx3aGF0ZXZlci5uZXSCEHd3dy53aGF0 +ZXZlci5uZXSCEGFwaS53aGF0ZXZlci5uZXQwDQYJKoZIhvcNAQELBQADggEBAB21 +KZYjarfd8nUAbwhH8dWZOo4rFcdYFo3xcXPQ11b1Wa79dtG67cgD/dplKFis5qD3 +6h4m818w9ESBA3Q1ZUy6HgDPMhCjg2fmCnSsZ5epo47wzvelYonfOX5DAwxgfYsa +335olrXJ0qsTiNmaS7RxDT53vfMOp41NyEAkFmpIAkaHgW/+xFPUSCBXIUWbaCG+ +pK3FVNmK3VCVCAP6UvVKYQUWSC6FRG/Q8MHoecdo+bbMlr2s2GPxq9TKInwe8JqT +E9pD7QMsN7uWpMaXNKCje4+Q88Br4URNcGAiYoy4/6hcF2Ki1saTYVIk/DG1P4hX +G5f0ezDLtsC22xe6jHI= +-----END CERTIFICATE REQUEST----- diff --git a/index.js b/index.js index d2e8011..a4b74e6 100644 --- a/index.js +++ b/index.js @@ -1,2 +1,2 @@ 'use strict'; -module.exports = require('./lib/rsa-csr.js'); +module.exports = require('./lib/csr.js'); diff --git a/lib/asn1.js b/lib/asn1.js index e69de29..c92c1c3 100644 --- a/lib/asn1.js +++ b/lib/asn1.js @@ -0,0 +1,72 @@ +'use strict'; + +// +// A dumbed-down, minimal ASN.1 parser / packer combo +// +// Note: generally I like to write congruent code +// (i.e. output can be used as input and vice-versa) +// However, this seemed to be more readable and easier +// to use written as-is, asymmetrically. +// (I also generally prefer to export objects rather +// functions but, yet again, asthetics one in this case) + +var Enc = require('./encoding.js'); + +// +// Packer +// + +// Almost every ASN.1 type that's important for CSR +// can be represented generically with only a few rules. +var ASN1 = module.exports = function ASN1(/*type, hexstrings...*/) { + var args = Array.prototype.slice.call(arguments); + var typ = args.shift(); + var str = args.join('').replace(/\s+/g, '').toLowerCase(); + var len = (str.length/2); + var lenlen = 0; + var hex = typ; + + // We can't have an odd number of hex chars + if (len !== Math.round(len)) { + console.error(arguments); + throw new Error("invalid hex"); + } + + // The first byte of any ASN.1 sequence is the type (Sequence, Integer, etc) + // The second byte is either the size of the value, or the size of its size + + // 1. If the second byte is < 0x80 (128) it is considered the size + // 2. If it is > 0x80 then it describes the number of bytes of the size + // ex: 0x82 means the next 2 bytes describe the size of the value + // 3. The special case of exactly 0x80 is "indefinite" length (to end-of-file) + + if (len > 127) { + lenlen += 1; + while (len > 255) { + lenlen += 1; + len = len >> 8; + } + } + + if (lenlen) { hex += Enc.numToHex(0x80 + lenlen); } + return hex + Enc.numToHex(str.length/2) + str; +}; + +// The Integer type has some special rules +ASN1.UInt = function UINT() { + var str = Array.prototype.slice.call(arguments).join(''); + var first = parseInt(str.slice(0, 2), 16); + + // If the first byte is 0x80 or greater, the number is considered negative + // Therefore we add a '00' prefix if the 0x80 bit is set + if (0x80 & first) { str = '00' + str; } + + return ASN1('02', str); +}; + +// The Bit String type also has a special rule +ASN1.BitStr = function BITSTR() { + var str = Array.prototype.slice.call(arguments).join(''); + // '00' is a mask of how many bits of the next byte to ignore + return ASN1('03', '00' + str); +}; diff --git a/lib/csr.js b/lib/csr.js new file mode 100644 index 0000000..73654c6 --- /dev/null +++ b/lib/csr.js @@ -0,0 +1,122 @@ +'use strict'; + +var crypto = require('crypto'); +var ASN1 = require('./asn1.js'); +var Enc = require('./encoding.js'); +var PEM = require('./pem.js'); +var X509 = require('./x509.js'); +var RSA = {}; + +/*global Promise*/ +var CSR = module.exports = function rsacsr(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 Promise.resolve().then(function () { + var Rasha; + opts = JSON.parse(JSON.stringify(opts)); + var pem, jwk; + + // 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.pem) { + pem = opts.pem; + } else if (opts.jwk) { + jwk = opts.jwk; + } else { + if (!opts.key) { + throw new Error("You must pass options.key as a JSON web key"); + } else if (opts.key.kty) { + jwk = opts.key; + } else { + pem = opts.key; + } + } + + if (pem) { + try { + Rasha = require('rasha'); + } catch(e) { + throw new Error("Rasha.js is an optional dependency for PEM-to-JWK.\n" + + "Install it if you'd like to use it:\n" + + "\tnpm install --save rasha\n" + + "Otherwise supply a jwk as the private key." + ); + } + jwk = Rasha.importSync({ pem: pem }); + } + + opts.jwk = jwk; + return CSR.create(opts).then(function (bytes) { + return PEM.packBlock({ + type: "CERTIFICATE REQUEST" + , bytes: bytes /* { jwk: jwk, domains: opts.domains } */ + }); + }); + }); +}; + +CSR.create = function createCsr(opts) { + var hex = CSR.request(opts.jwk, opts.domains); + return CSR.sign(opts.jwk, hex).then(function (csr) { + return Enc.hexToBuf(csr); + }); +}; + +CSR.request = function createCsrBodyEc(jwk, domains) { + var asn1pub = X509.packCsrPublicKey(jwk); + return X509.packCsr(asn1pub, domains); +}; + +CSR.sign = function csrEcSig(jwk, request) { + var keypem = PEM.packBlock({ type: "RSA PRIVATE KEY", bytes: X509.packPkcs1(jwk) }); + + return RSA.sign(keypem, Enc.hexToBuf(request)).then(function (sig) { + var sty = ASN1('30' + // 1.2.840.113549.1.1.11 sha256WithRSAEncryption (PKCS #1) + , ASN1('06', '2a864886f70d01010b') + , ASN1('05') + ); + return ASN1('30' + // The Full CSR Request Body + , request + // The Signature Type + , sty + // The Signature + , ASN1.BitStr(Enc.bufToHex(sig)) + ); + }); +}; + +// +// RSA +// + +// Took some tips from https://gist.github.com/codermapuche/da4f96cdb6d5ff53b7ebc156ec46a10a +RSA.sign = function signRsa(keypem, ab) { + return Promise.resolve().then(function () { + // Signer is a stream + var sign = crypto.createSign('SHA256'); + sign.write(new Uint8Array(ab)); + sign.end(); + + // The signature is ASN1 encoded, as it turns out + var sig = sign.sign(keypem); + + // Convert to a JavaScript ArrayBuffer just because + return new Uint8Array(sig.buffer.slice(sig.byteOffset, sig.byteOffset + sig.byteLength)); + }); +}; diff --git a/lib/encoding.js b/lib/encoding.js index e69de29..f6b6ab0 100644 --- a/lib/encoding.js +++ b/lib/encoding.js @@ -0,0 +1,34 @@ +'use strict'; + +var Enc = module.exports; + +Enc.base64ToHex = function base64ToHex(b64) { + return Buffer.from(b64, 'base64').toString('hex').toLowerCase(); +}; + +Enc.bufToBase64 = function bufToBase64(u8) { + // we want to maintain api compatability with browser APIs, + // so we assume that this could be a Uint8Array + return Buffer.from(u8).toString('base64'); +}; + +Enc.bufToHex = function toHex(u8) { + return Buffer.from(u8).toString('hex').toLowerCase(); +}; + +Enc.hexToBuf = function (hex) { + return Buffer.from(hex, 'hex'); +}; + +Enc.numToHex = function numToHex(d) { + d = d.toString(16); + if (d.length % 2) { + return '0' + d; + } + return d; +}; + +Enc.utf8ToHex = function utf8ToHex(str) { + // node can properly handle utf-8 strings + return Buffer.from(str).toString('hex').toLowerCase(); +}; diff --git a/lib/pem.js b/lib/pem.js index e69de29..1c0f709 100644 --- a/lib/pem.js +++ b/lib/pem.js @@ -0,0 +1,12 @@ +'use strict'; + +var Enc = require('./encoding.js') +var PEM = module.exports; + +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/lib/rsa-csr.js b/lib/rsa-csr.js deleted file mode 100644 index e83b7ef..0000000 --- a/lib/rsa-csr.js +++ /dev/null @@ -1,5 +0,0 @@ -'use strict'; - -module.exports = function rsacsr(opts) { - throw new Error("not implemented yet"); -}; diff --git a/lib/x509.js b/lib/x509.js index e69de29..33cb9c5 100644 --- a/lib/x509.js +++ b/lib/x509.js @@ -0,0 +1,66 @@ +'use strict'; + +var ASN1 = require('./asn1.js'); +var Enc = require('./encoding.js'); + +var X509 = module.exports; + +X509.packCsr = function (asn1pubkey, domains) { + return ASN1('30' + // Version (0) + , ASN1.UInt('00') + + // 2.5.4.3 commonName (X.520 DN component) + , ASN1('30', ASN1('31', ASN1('30', ASN1('06', '550403'), ASN1('0c', Enc.utf8ToHex(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) { + return ASN1('82', Enc.utf8ToHex(d)); + }).join('')))))))) + ); +}; + +X509.packPkcs1 = function (jwk) { + var n = ASN1.UInt(Enc.base64ToHex(jwk.n)); + var e = ASN1.UInt(Enc.base64ToHex(jwk.e)); + + if (!jwk.d) { + return Enc.hexToBuf(ASN1('30', n, e)); + } + + return Enc.hexToBuf(ASN1('30' + , ASN1.UInt('00') + , n + , e + , ASN1.UInt(Enc.base64ToHex(jwk.d)) + , ASN1.UInt(Enc.base64ToHex(jwk.p)) + , ASN1.UInt(Enc.base64ToHex(jwk.q)) + , ASN1.UInt(Enc.base64ToHex(jwk.dp)) + , ASN1.UInt(Enc.base64ToHex(jwk.dq)) + , ASN1.UInt(Enc.base64ToHex(jwk.qi)) + )); +}; + +X509.packCsrPublicKey = function (jwk) { + // Sequence the key + var n = ASN1.UInt(Enc.base64ToHex(jwk.n)); + var e = ASN1.UInt(Enc.base64ToHex(jwk.e)); + var asn1pub = ASN1('30', n, e); + //var asn1pub = X509.packPkcs1({ kty: jwk.kty, n: jwk.n, e: jwk.e }); + + // Add the CSR pub key header + return ASN1('30', ASN1('30', ASN1('06', '2a864886f70d010101'), ASN1('05')), ASN1.BitStr(asn1pub)); +}; diff --git a/test.sh b/test.sh index d3fb11e..c53b247 100755 --- a/test.sh +++ b/test.sh @@ -1,6 +1,7 @@ #!/bin/bash +set -e -gencsr() { +gencsr2() { keyfile=$1 domain=$2 csrfile=$3 @@ -22,4 +23,65 @@ DNS.2 = www.$domain") \ -out $csrfile } -gencsr fixtures/privkey-rsa-2048.pkcs1.pem example.com fixtures/example.com-www.csr.pem +gencsr3() { + keyfile=$1 + domain=$2 + csrfile=$3 + openssl req -key $keyfile -new -nodes \ + -config <(printf "[req] +prompt = no +req_extensions = req_ext +distinguished_name = dn + +[ dn ] +CN = $domain + +[ req_ext ] +subjectAltName = @alt_names + +[ alt_names ] +DNS.1 = $domain +DNS.2 = www.$domain +DNS.3 = api.$domain") \ + -out $csrfile +} + +rndcsr() { + keysize=$1 + openssl genrsa -out fixtures/valid.pkcs1.1.pem $keysize + rasha fixtures/valid.pkcs1.1.pem > fixtures/test.jwk.1.json + gencsr3 fixtures/valid.pkcs1.1.pem whatever.net fixtures/valid.csr.1.pem + node bin/rsa-csr.js fixtures/test.jwk.1.json whatever.net,www.whatever.net,api.whatever.net \ + > fixtures/test.csr.1.pem + diff fixtures/valid.csr.1.pem fixtures/test.csr.1.pem +} + +echo "" +echo "Generating CSR for example.com,www.example.com" +gencsr2 fixtures/privkey-rsa-2048.pkcs1.pem example.com fixtures/example.com-www.csr.pem +node bin/rsa-csr.js fixtures/privkey-rsa-2048.jwk.json example.com,www.example.com \ + > fixtures/example.com-www.csr.1.pem +diff fixtures/example.com-www.csr.pem fixtures/example.com-www.csr.1.pem +echo "Pass" + +echo "" +echo "Generating CSR for whatever.net,www.whatever.net,api.whatever.net" +gencsr3 fixtures/privkey-rsa-2048.pkcs1.pem whatever.net fixtures/whatever.net-www-api.csr.pem +node bin/rsa-csr.js fixtures/privkey-rsa-2048.jwk.json whatever.net,www.whatever.net,api.whatever.net \ + > fixtures/whatever.net-www-api.csr.1.pem +diff fixtures/whatever.net-www-api.csr.pem fixtures/whatever.net-www-api.csr.1.pem +echo "Pass" + +echo "" +echo "Generating random keys of various lengths and re-running tests for each" +rndcsr 3072 +rndcsr 1024 +rndcsr 512 # minimum size that can reasonably work +echo "Pass" + +rm fixtures/*.1.* + +echo "" +echo "All tests passed!" +echo " • Fixture CSRs built and do not differ from OpenSSL-generated CSRs" +echo " • Random keys and CSRs are also correct"