diff --git a/app.js b/app.js index 3531ee2..bbac95c 100644 --- a/app.js +++ b/app.js @@ -6,7 +6,9 @@ var Rasha = window.Rasha; var Eckles = window.Eckles; var x509 = window.x509; + var CSR = window.CSR; var ACME = window.ACME; + var accountStuff = {}; function $(sel) { return document.querySelector(sel); @@ -15,6 +17,14 @@ return Array.prototype.slice.call(document.querySelectorAll(sel)); } + function checkTos(tos) { + if ($('input[name="tos"]:checked')) { + return tos; + } else { + return ''; + } + } + function run() { console.log('hello'); @@ -51,8 +61,10 @@ , namedCurve: $('input[name="ec-crv"]:checked').value , modulusLength: $('input[name="rsa-len"]:checked').value }; + var then = Date.now(); console.log('opts', opts); Keypairs.generate(opts).then(function (results) { + console.log("Key generation time:", (Date.now() - then) + "ms"); var pubDer; var privDer; if (/EC/i.test(opts.kty)) { @@ -101,6 +113,9 @@ $$('input').map(function ($el) { $el.disabled = false; }); $$('button').map(function ($el) { $el.disabled = false; }); $('.js-toc-jwk').hidden = false; + + $('.js-create-account').hidden = false; + $('.js-create-csr').hidden = false; }); }); @@ -110,56 +125,25 @@ $('.js-loading').hidden = false; var acme = ACME.create({ Keypairs: Keypairs + , CSR: CSR }); acme.init('https://acme-staging-v02.api.letsencrypt.org/directory').then(function (result) { console.log('acme result', result); var privJwk = JSON.parse($('.js-jwk').innerText).private; - var email = $('.js-email').innerText; - function checkTos(tos) { - console.log("TODO checkbox for agree to terms"); - return tos; - } + var email = $('.js-email').value; return acme.accounts.create({ email: email , agreeToTerms: checkTos , accountKeypair: { privateKeyJwk: privJwk } }).then(function (account) { console.log("account created result:", account); - return Keypairs.generate({ - kty: 'RSA' - , modulusLength: 2048 - }).then(function (pair) { - console.log('domain keypair:', pair); - var domains = ($('.js-domains').innerText||'example.com').split(/[, ]+/g); - return acme.certificates.create({ - accountKeypair: { privateKeyJwk: privJwk } - , account: account - , domainKeypair: { privateKeyJwk: pair.private } - , email: email - , domains: domains - , agreeToTerms: checkTos - , challenges: { - 'dns-01': { - set: function (opts) { - console.log('dns-01 set challenge:'); - console.log(JSON.stringify(opts, null, 2)); - return new Promise(function (resolve) { - while (!window.confirm("Did you set the challenge?")) {} - resolve(); - }); - } - , remove: function (opts) { - console.log('dns-01 remove challenge:'); - console.log(JSON.stringify(opts, null, 2)); - return new Promise(function (resolve) { - while (!window.confirm("Did you delete the challenge?")) {} - resolve(); - }); - } - } - } - }); - }); + accountStuff.account = account; + accountStuff.privateJwk = privJwk; + accountStuff.email = email; + accountStuff.acme = acme; + $('.js-create-order').hidden = false; + $('.js-toc-acme-account-response').hidden = false; + $('.js-acme-account-response').innerText = JSON.stringify(account, null, 2); }).catch(function (err) { console.error("A bad thing happened:"); console.error(err); @@ -168,8 +152,123 @@ }); }); + $('form.js-csr').addEventListener('submit', function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + generateCsr(); + }); + + $('form.js-acme-order').addEventListener('submit', function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + var account = accountStuff.account; + var privJwk = accountStuff.privateJwk; + var email = accountStuff.email; + var acme = accountStuff.acme; + + + var domains = ($('.js-domains').value||'example.com').split(/[, ]+/g); + return getDomainPrivkey().then(function (domainPrivJwk) { + console.log('Has CSR already?'); + console.log(accountStuff.csr); + return acme.certificates.create({ + accountKeypair: { privateKeyJwk: privJwk } + , account: account + , domainKeypair: { privateKeyJwk: domainPrivJwk } + , csr: accountStuff.csr + , email: email + , domains: domains + , skipDryRun: $('input[name="skip-dryrun"]:checked') && true + , agreeToTerms: checkTos + , challenges: { + 'dns-01': { + set: function (opts) { + console.info('dns-01 set challenge:'); + console.info('TXT', opts.dnsHost); + console.info(opts.dnsAuthorization); + return new Promise(function (resolve) { + while (!window.confirm("Did you set the challenge?")) {} + resolve(); + }); + } + , remove: function (opts) { + console.log('dns-01 remove challenge:'); + console.info('TXT', opts.dnsHost); + console.info(opts.dnsAuthorization); + return new Promise(function (resolve) { + while (!window.confirm("Did you delete the challenge?")) {} + resolve(); + }); + } + } + , 'http-01': { + set: function (opts) { + console.info('http-01 set challenge:'); + console.info(opts.challengeUrl); + console.info(opts.keyAuthorization); + return new Promise(function (resolve) { + while (!window.confirm("Did you set the challenge?")) {} + resolve(); + }); + } + , remove: function (opts) { + console.log('http-01 remove challenge:'); + console.info(opts.challengeUrl); + console.info(opts.keyAuthorization); + return new Promise(function (resolve) { + while (!window.confirm("Did you delete the challenge?")) {} + resolve(); + }); + } + } + } + , challengeTypes: [$('input[name="acme-challenge-type"]:checked').value] + }).then(function (results) { + console.log('Got Certificates:'); + console.log(results); + $('.js-toc-acme-order-response').hidden = false; + $('.js-acme-order-response').innerText = JSON.stringify(results, null, 2); + }).catch(function (err) { + console.error("challenge failed:"); + console.error(err); + window.alert("failed! " + err.message || JSON.stringify(err)); + }); + }); + }); + $('.js-generate').hidden = false; - $('.js-create-account').hidden = false; + } + + function getDomainPrivkey() { + if (accountStuff.domainPrivateJwk) { return Promise.resolve(accountStuff.domainPrivateJwk); } + return Keypairs.generate({ + kty: $('input[name="kty"]:checked').value + , namedCurve: $('input[name="ec-crv"]:checked').value + , modulusLength: $('input[name="rsa-len"]:checked').value + }).then(function (pair) { + console.log('domain keypair:', pair); + accountStuff.domainPrivateJwk = pair.private; + return pair.private; + }); + } + + function generateCsr() { + var domains = ($('.js-domains').value||'example.com').split(/[, ]+/g); + //var privJwk = JSON.parse($('.js-jwk').innerText).private; + return getDomainPrivkey().then(function (privJwk) { + accountStuff.domainPrivateJwk = privJwk; + return CSR({ jwk: privJwk, domains: domains }).then(function (pem) { + // Verify with https://www.sslshopper.com/csr-decoder.html + accountStuff.csr = pem; + console.log('Created CSR:'); + console.log(pem); + + console.log('CSR info:'); + console.log(CSR._info(pem)); + + return pem; + }); + }); } window.addEventListener('load', run); diff --git a/index.html b/index.html index b4d91c8..27f0aa5 100644 --- a/index.html +++ b/index.html @@ -34,27 +34,21 @@

EC Options:

- - - - - + + +
@@ -62,14 +56,36 @@

ACME Account

- +
- - +
+

Certificate Signing Request

+
+ + +
+ +
+ +

ACME Certificate Order

+
+ Challenge type: + + +
+ +
+ +
+ - + + + diff --git a/lib/acme.js b/lib/acme.js index d5b3438..ad966ec 100644 --- a/lib/acme.js +++ b/lib/acme.js @@ -8,6 +8,7 @@ var ACME = exports.ACME = {}; //var Keypairs = exports.Keypairs || {}; +//var CSR = exports.CSR; var Enc = exports.Enc || {}; var Crypto = exports.Crypto || {}; @@ -29,20 +30,19 @@ ACME.challengePrefixes = { }; ACME.challengeTests = { 'http-01': function (me, auth) { - var url = 'http://' + auth.hostname + ACME.challengePrefixes['http-01'] + '/' + auth.token; - return me.request({ method: 'GET', url: url }).then(function (resp) { + return me.http01(auth).then(function (keyAuth) { var err; // TODO limit the number of bytes that are allowed to be downloaded - if (auth.keyAuthorization === resp.body.toString('utf8').trim()) { + if (auth.keyAuthorization === (keyAuth||'').trim()) { return true; } err = new Error( "Error: Failed HTTP-01 Pre-Flight / Dry Run.\n" - + "curl '" + url + "'\n" + + "curl '" + auth.challengeUrl + "'\n" + "Expected: '" + auth.keyAuthorization + "'\n" - + "Got: '" + resp.body + "'\n" + + "Got: '" + keyAuth + "'\n" + "See https://git.coolaj86.com/coolaj86/acme-v2.js/issues/4" ); err.code = 'E_FAIL_DRY_CHALLENGE'; @@ -51,10 +51,7 @@ ACME.challengeTests = { } , 'dns-01': function (me, auth) { // remove leading *. on wildcard domains - return me.dig({ - type: 'TXT' - , name: auth.dnsHost - }).then(function (ans) { + return me.dns01(auth).then(function (ans) { var err; if (ans.answer.some(function (txt) { @@ -154,7 +151,7 @@ ACME._registerAccount = function (me, options) { , kid: options.externalAccount.id , url: me._directoryUrls.newAccount } - , payload: Enc.strToBuf(JSON.stringify(pair.public)) + , payload: Enc.binToBuf(JSON.stringify(pair.public)) }).then(function (jws) { body.externalAccountBinding = jws; return body; @@ -288,10 +285,6 @@ ACME._testChallengeOptions = function () { ]; }; ACME._testChallenges = function (me, options) { - if (me.skipChallengeTest) { - return Promise.resolve(); - } - var CHECK_DELAY = 0; return Promise.all(options.domains.map(function (identifierValue) { // TODO we really only need one to pass, not all to pass @@ -311,6 +304,12 @@ ACME._testChallenges = function (me, options) { + " You must enable one of ( " + suitable + " )." )); } + + // TODO remove skipChallengeTest + if (me.skipDryRun || me.skipChallengeTest) { + return null; + } + if ('dns-01' === challenge.type) { // Give the nameservers a moment to propagate CHECK_DELAY = 1.5 * 1000; @@ -326,17 +325,27 @@ ACME._testChallenges = function (me, options) { , expires: new Date(Date.now() + (60 * 1000)).toISOString() , wildcard: identifierValue.includes('*.') || undefined }; + + // The dry-run comes first in the spirit of "fail fast" + // (and protecting against challenge failure rate limits) var dryrun = true; return ACME._challengeToAuth(me, options, results, challenge, dryrun).then(function (auth) { + if (!me._canUse[auth.type]) { return; } return ACME._setChallenge(me, options, auth).then(function () { return auth; }); }); }); })).then(function (auths) { + auths = auths.filter(Boolean); + if (!auths.length) { /*skip actual test*/ return; } return ACME._wait(CHECK_DELAY).then(function () { return Promise.all(auths.map(function (auth) { - return ACME.challengeTests[auth.type](me, auth); + return ACME.challengeTests[auth.type](me, auth).then(function (result) { + // not a blocker + ACME._removeChallenge(me, options, auth); + return result; + }); })); }); }); @@ -390,6 +399,7 @@ ACME._challengeToAuth = function (me, options, request, challenge, dryrun) { // keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey)) auth.keyAuthorization = challenge.token + '.' + auth.thumbprint; // conflicts with ACME challenge id url is already in use, so we call this challengeUrl instead + // TODO auth.http01Url ? auth.challengeUrl = 'http://' + auth.identifier.value + ACME.challengePrefixes['http-01'] + '/' + auth.token; auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', ''); @@ -436,11 +446,11 @@ ACME._postChallenge = function (me, options, auth) { */ function deactivate() { if (me.debug) { console.debug('[acme-v2.js] deactivate:'); } - return ACME._jwsRequest({ + return ACME._jwsRequest(me, { options: options , url: auth.url , protected: { kid: options._kid } - , payload: Enc.strToBuf(JSON.stringify({ "status": "deactivated" })) + , payload: Enc.binToBuf(JSON.stringify({ "status": "deactivated" })) }).then(function (resp) { if (me.debug) { console.debug('deactivate challenge: resp.body:'); } if (me.debug) { console.debug(resp.body); } @@ -478,18 +488,7 @@ ACME._postChallenge = function (me, options, auth) { if (me.debug) { console.debug('poll: valid'); } try { - if (1 === options.removeChallenge.length) { - options.removeChallenge(auth).then(function () {}, function () {}); - } else if (2 === options.removeChallenge.length) { - options.removeChallenge(auth, function (err) { return err; }); - } else { - if (!ACME._removeChallengeWarn) { - console.warn("Please update to acme-v2 removeChallenge(options) or removeChallenge(options, cb)."); - console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types."); - ACME._removeChallengeWarn = true; - } - options.removeChallenge(auth.request.identifier, auth.token, function () {}); - } + ACME._removeChallenge(me, options, auth); } catch(e) {} return resp.body; } @@ -511,11 +510,11 @@ ACME._postChallenge = function (me, options, auth) { function respondToChallenge() { if (me.debug) { console.debug('[acme-v2.js] responding to accept challenge:'); } - return ACME._jwsRequest({ + return ACME._jwsRequest(me, { options: options , url: auth.url , protected: { kid: options._kid } - , payload: Enc.strToBuf(JSON.stringify({})) + , payload: Enc.binToBuf(JSON.stringify({})) }).then(function (resp) { if (me.debug) { console.debug('respond to challenge: resp.body:'); } if (me.debug) { console.debug(resp.body); } @@ -526,8 +525,6 @@ ACME._postChallenge = function (me, options, auth) { return respondToChallenge(); }; ACME._setChallenge = function (me, options, auth) { - console.log('challenge auth:', auth); - console.log('challenges:', options.challenges); return new Promise(function (resolve, reject) { var challengers = options.challenges || {}; var challenger = (challengers[auth.type] && challengers[auth.type].set) || options.setChallenge; @@ -572,11 +569,11 @@ ACME._finalizeOrder = function (me, options, validatedDomains) { function pollCert() { if (me.debug) { console.debug('[acme-v2.js] pollCert:'); } - return ACME._jwsRequest({ + return ACME._jwsRequest(me, { options: options , url: options._finalize , protected: { kid: options._kid } - , payload: Enc.strToBuf(payload) + , payload: Enc.binToBuf(payload) }).then(function (resp) { if (me.debug) { console.debug('order finalized: resp.body:'); } if (me.debug) { console.debug(resp.body); } @@ -674,6 +671,14 @@ ACME._getCertificate = function (me, options) { return Promise.reject(new Error("options.challengeTypes (string array) must be specified" + " (and in order of preferential priority).")); } + if (options.csr) { + // TODO validate csr signature + options._csr = me.CSR._info(options.csr); + options.domains = options._csr.altnames; + if (options._csr.subject !== options.domains[0]) { + return Promise.reject(new Error("certificate subject (commonName) does not match first altname (SAN)")); + } + } if (!(options.domains && options.domains.length)) { return Promise.reject(new Error("options.domains must be a list of string domain names," + " with the first being the subject of the certificate (or options.subject must specified).")); @@ -713,14 +718,15 @@ ACME._getCertificate = function (me, options) { var payload = JSON.stringify(body); if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); } - return ACME._jwsRequest({ + return ACME._jwsRequest(me, { options: options , url: me._directoryUrls.newOrder , protected: { kid: options._kid } - , payload: Enc.strToBuf(payload) + , payload: Enc.binToBuf(payload) }).then(function (resp) { var location = resp.headers.location; var setAuths; + var validAuths = []; var auths = []; if (me.debug) { console.debug('[ordered]', location); } // the account id url if (me.debug) { console.debug(resp); } @@ -765,16 +771,32 @@ ACME._getCertificate = function (me, options) { }); } - function challengeNext() { + function checkNext() { var auth = auths.shift(); if (!auth) { return; } + + if (!me._canUse[auth.type] || me.skipChallengeTest) { + // not so much "valid" as "not invalid" + // but in this case we can't confirm either way + validAuths.push(auth); + return Promise.resolve(); + } + + return ACME.challengeTests[auth.type](me, auth).then(function () { + validAuths.push(auth); + }).then(checkNext); + } + + function challengeNext() { + var auth = validAuths.shift(); + if (!auth) { return; } return ACME._postChallenge(me, options, auth).then(challengeNext); } // First we set every challenge // Then we ask for each challenge to be checked // Doing otherwise would potentially cause us to poison our own DNS cache with misses - return setNext().then(challengeNext).then(function () { + return setNext().then(checkNext).then(challengeNext).then(function () { if (me.debug) { console.debug("[getCertificate] next.then"); } var validatedDomains = body.identifiers.map(function (ident) { return ident.value; @@ -805,8 +827,19 @@ ACME._getCertificate = function (me, options) { }); }; ACME._generateCsrWeb64 = function (me, options, validatedDomains) { - return ACME._importKeypair(me, options.domainKeypair).then(function (/*pair*/) { - return me.Keypairs.generateCsr(options.domainKeypair, validatedDomains).then(function (der) { + var csr; + if (options.csr) { + csr = options.csr; + // if der, convert to base64 + if ('string' !== typeof csr) { csr = Enc.bufToUrlBase64(csr); } + // nix PEM headers, if any + if ('-' === csr[0]) { csr = csr.split(/\n+/).slice(1, -1).join(''); } + csr = Enc.base64ToUrlBase64(csr.trim().replace(/\s+/g, '')); + return Promise.resolve(csr); + } + + return ACME._importKeypair(me, options.domainKeypair).then(function (pair) { + return me.CSR({ jwk: pair.private, domains: validatedDomains, encoding: 'der' }).then(function (der) { return Enc.bufToUrlBase64(der); }); }); @@ -816,23 +849,25 @@ ACME.create = function create(me) { if (!me) { me = {}; } // me.debug = true; me.challengePrefixes = ACME.challengePrefixes; - me.Keypairs = me.Keypairs || me.RSA || require('rsa-compat').RSA; + me.Keypairs = me.Keypairs || exports.Keypairs || require('keypairs').Keypairs; + me.CSR = me.CSR || exports.cSR || require('CSR').CSR; me._nonces = []; + me._canUse = {}; + if (!me._baseUrl) { + me._baseUrl = ""; + } //me.Keypairs = me.Keypairs || require('keypairs'); //me.request = me.request || require('@root/request'); - if (!me.dig) { - me.dig = function (query) { - // TODO use digd.js - return new me.request({ url: "/api/dns/" + query.name + "?type=" + query.type }).then(function (resp) { - if (!resp.body || !Array.isArray(resp.body.answer)) { - throw new Error("failed to get DNS response"); - } - return { - answer: resp.body.answer.map(function (ans) { - return { data: ans.data, ttl: ans.ttl }; - }) - }; - }); + if (!me.dns01) { + me.dns01 = function (auth) { + return ACME._dns01(me, auth); + }; + } + // backwards compat + if (!me.dig) { me.dig = me.dns01; } + if (!me.http01) { + me.http01 = function (auth) { + return ACME._http01(me, auth); }; } @@ -853,8 +888,21 @@ ACME.create = function create(me) { if ('string' !== typeof me.directoryUrl) { throw new Error("you must supply either the ACME directory url as a string or an object of the ACME urls"); } - return ACME._directory(me).then(function (resp) { - return fin(resp.body); + var p = Promise.resolve(); + if (!me.skipChallengeTest) { + p = me.request({ url: me._baseUrl + "/api/_acme_api_/" }).then(function (resp) { + if (resp.body.success) { + me._canCheck['http-01'] = true; + me._canCheck['dns-01'] = true; + } + }).catch(function () { + // ignore + }); + } + return p.then(function () { + return ACME._directory(me).then(function (resp) { + return fin(resp.body); + }); }); }; me.accounts = { @@ -876,6 +924,10 @@ ACME._jwsRequest = function (me, bigopts) { bigopts.protected.nonce = nonce; bigopts.protected.url = bigopts.url; // protected.alg: added by Keypairs.signJws + if (!bigopts.protected.jwk) { + // protected.kid must be overwritten due to ACME's interpretation of the spec + if (!bigopts.protected.kid) { bigopts.protected.kid = bigopts.options._kid; } + } return me.Keypairs.signJws( { jwk: bigopts.options.accountKeypair.privateKeyJwk , protected: bigopts.protected @@ -992,6 +1044,48 @@ ACME._prnd = function (n) { ACME._toHex = function (pair) { return parseInt(pair, 10).toString(16); }; +ACME._dns01 = function (me, auth) { + return new me.request({ url: me._baseUrl + "/api/dns/" + auth.dnsHost + "?type=TXT" }).then(function (resp) { + var err; + if (!resp.body || !Array.isArray(resp.body.answer)) { + err = new Error("failed to get DNS response"); + console.error(err); + throw err; + } + if (!resp.body.answer.length) { + err = new Error("failed to get DNS answer record in response"); + console.error(err); + throw err; + } + return { + answer: resp.body.answer.map(function (ans) { + return { data: ans.data, ttl: ans.ttl }; + }) + }; + }); +}; +ACME._http01 = function (me, auth) { + var url = encodeURIComponent(auth.challengeUrl); + return new me.request({ url: me._baseUrl + "/api/http?url=" + url }).then(function (resp) { + return resp.body; + }); +}; +ACME._removeChallenge = function (me, options, auth) { + var challengers = options.challenges || {}; + var removeChallenge = (challengers[auth.type] && challengers[auth.type].remove) || options.removeChallenge; + if (1 === removeChallenge.length) { + removeChallenge(auth).then(function () {}, function () {}); + } else if (2 === removeChallenge.length) { + removeChallenge(auth, function (err) { return err; }); + } else { + if (!ACME._removeChallengeWarn) { + console.warn("Please update to acme-v2 removeChallenge(options) or removeChallenge(options, cb)."); + console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types."); + ACME._removeChallengeWarn = true; + } + removeChallenge(auth.request.identifier, auth.token, function () {}); + } +}; Enc.bufToUrlBase64 = function (u8) { return Enc.bufToBase64(u8) diff --git a/lib/asn1-parser.js b/lib/asn1-parser.js index 82f7cd0..9314aa3 100644 --- a/lib/asn1-parser.js +++ b/lib/asn1-parser.js @@ -125,7 +125,7 @@ PEM.parseBlock = PEM.parseBlock || function (str) { var der = str.split(/\n/).filter(function (line) { return !/-----/.test(line); }).join(''); - return { der: Enc.base64ToBuf(der) }; + return { bytes: Enc.base64ToBuf(der) }; }; Enc.base64ToBuf = function (b64) { diff --git a/lib/bluecrypt-encoding.js b/lib/bluecrypt-encoding.js index 7dc1073..a9609e5 100644 --- a/lib/bluecrypt-encoding.js +++ b/lib/bluecrypt-encoding.js @@ -66,8 +66,11 @@ Enc.numToHex = function (d) { }; Enc.bufToUrlBase64 = function (u8) { - return Enc.bufToBase64(u8) - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + return Enc.base64ToUrlBase64(Enc.bufToBase64(u8)); +}; + +Enc.base64ToUrlBase64 = function (str) { + return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); }; Enc.bufToBase64 = function (u8) { @@ -110,6 +113,8 @@ Enc.binToHex = function (bin) { return h; }).join(''); }; +// TODO are there any nuance differences here? +Enc.utf8ToHex = Enc.binToHex; Enc.hexToBase64 = function (hex) { return btoa(Enc.hexToBin(hex)); diff --git a/lib/browser-acme.js b/lib/browser-acme.js deleted file mode 100644 index 4fba0fe..0000000 --- a/lib/browser-acme.js +++ /dev/null @@ -1,699 +0,0 @@ -/*global CSR*/ -// CSR takes a while to load after the page load -(function (exports) { -'use strict'; - -var BACME = exports.ACME = {}; -var webFetch = exports.fetch; -var Keypairs = exports.Keypairs; -var Promise = exports.Promise; - -var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; -var directory; - -var nonceUrl; -var nonce; - -var accountKeypair; -var accountJwk; - -var accountUrl; - -BACME.challengePrefixes = { - 'http-01': '/.well-known/acme-challenge' -, 'dns-01': '_acme-challenge' -}; - -BACME._logHeaders = function (resp) { - console.log('Headers:'); - Array.from(resp.headers.entries()).forEach(function (h) { console.log(h[0] + ': ' + h[1]); }); -}; - -BACME._logBody = function (body) { - console.log('Body:'); - console.log(JSON.stringify(body, null, 2)); - console.log(''); -}; - -BACME.directory = function (opts) { - return webFetch(opts.directoryUrl || directoryUrl, { mode: 'cors' }).then(function (resp) { - BACME._logHeaders(resp); - return resp.json().then(function (reply) { - if (/error/.test(reply.type)) { - return Promise.reject(new Error(reply.detail || reply.type)); - } - directory = reply; - nonceUrl = directory.newNonce || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce'; - accountUrl = directory.newAccount || 'https://acme-staging-v02.api.letsencrypt.org/acme/new-account'; - orderUrl = directory.newOrder || "https://acme-staging-v02.api.letsencrypt.org/acme/new-order"; - BACME._logBody(reply); - return reply; - }); - }); -}; - -BACME.nonce = function () { - return webFetch(nonceUrl, { mode: 'cors' }).then(function (resp) { - BACME._logHeaders(resp); - nonce = resp.headers.get('replay-nonce'); - console.log('Nonce:', nonce); - // resp.body is empty - return resp.headers.get('replay-nonce'); - }); -}; - -BACME.accounts = {}; - -// type = ECDSA -// bitlength = 256 -BACME.accounts.generateKeypair = function (opts) { - return BACME.generateKeypair(opts).then(function (result) { - accountKeypair = result; - - return webCrypto.subtle.exportKey( - "jwk" - , result.privateKey - ).then(function (privJwk) { - - accountJwk = privJwk; - console.log('private jwk:'); - console.log(JSON.stringify(privJwk, null, 2)); - - return privJwk; - /* - return webCrypto.subtle.exportKey( - "pkcs8" - , result.privateKey - ).then(function (keydata) { - console.log('pkcs8:'); - console.log(Array.from(new Uint8Array(keydata))); - - return privJwk; - //return accountKeypair; - }); - */ - }); - }); -}; - -// json to url-safe base64 -BACME._jsto64 = function (json) { - return btoa(JSON.stringify(json)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); -}; - -var textEncoder = new TextEncoder(); - -BACME._importKey = function (jwk) { - var alg; // I think the 256 refers to the hash - var wcOpts = {}; - var extractable = true; // TODO make optionally false? - var priv = jwk; - var pub; - - // ECDSA - if (/^EC/i.test(jwk.kty)) { - wcOpts.name = 'ECDSA'; - wcOpts.namedCurve = jwk.crv; - alg = 'ES256'; - pub = { - crv: priv.crv - , kty: priv.kty - , x: priv.x - , y: priv.y - }; - if (!priv.d) { - priv = null; - } - } - - // RSA - if (/^RS/i.test(jwk.kty)) { - wcOpts.name = 'RSASSA-PKCS1-v1_5'; - wcOpts.hash = { name: "SHA-256" }; - alg = 'RS256'; - pub = { - e: priv.e - , kty: priv.kty - , n: priv.n - }; - if (!priv.p) { - priv = null; - } - } - - return window.crypto.subtle.importKey( - "jwk" - , pub - , wcOpts - , extractable - , [ "verify" ] - ).then(function (publicKey) { - function give(privateKey) { - return { - wcPub: publicKey - , wcKey: privateKey - , wcKeypair: { publicKey: publicKey, privateKey: privateKey } - , meta: { - alg: alg - , name: wcOpts.name - , hash: wcOpts.hash - } - , jwk: jwk - }; - } - if (!priv) { - return give(); - } - return window.crypto.subtle.importKey( - "jwk" - , priv - , wcOpts - , extractable - , [ "sign"/*, "verify"*/ ] - ).then(give); - }); -}; -BACME._sign = function (opts) { - var wcPrivKey = opts.abstractKey.wcKeypair.privateKey; - var wcOpts = opts.abstractKey.meta; - var alg = opts.abstractKey.meta.alg; // I think the 256 refers to the hash - var signHash; - - console.log('kty', opts.abstractKey.jwk.kty); - signHash = { name: "SHA-" + alg.replace(/[a-z]+/ig, '') }; - - var msg = textEncoder.encode(opts.protected64 + '.' + opts.payload64); - console.log('msg:', msg); - return window.crypto.subtle.sign( - { name: wcOpts.name, hash: signHash } - , wcPrivKey - , msg - ).then(function (signature) { - //console.log('sig1:', signature); - //console.log('sig2:', new Uint8Array(signature)); - //console.log('sig3:', Array.prototype.slice.call(new Uint8Array(signature))); - // convert buffer to urlsafe base64 - var sig64 = btoa(Array.prototype.map.call(new Uint8Array(signature), function (ch) { - return String.fromCharCode(ch); - }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); - - console.log('[1] URL-safe Base64 Signature:'); - console.log(sig64); - - var signedMsg = { - protected: opts.protected64 - , payload: opts.payload64 - , signature: sig64 - }; - - console.log('Signed Base64 Msg:'); - console.log(JSON.stringify(signedMsg, null, 2)); - - return signedMsg; - }); -}; -// email = john.doe@gmail.com -// jwk = { ... } -// agree = true -BACME.accounts.sign = function (opts) { - - return BACME._importKey(opts.jwk).then(function (abstractKey) { - - var payloadJson = - { termsOfServiceAgreed: opts.agree - , onlyReturnExisting: false - , contact: opts.contacts || [ 'mailto:' + opts.email ] - }; - console.log('payload:'); - console.log(payloadJson); - var payload64 = BACME._jsto64( - payloadJson - ); - - var protectedJson = - { nonce: opts.nonce - , url: accountUrl - , alg: abstractKey.meta.alg - , jwk: null - }; - - if (/EC/i.test(opts.jwk.kty)) { - protectedJson.jwk = { - crv: opts.jwk.crv - , kty: opts.jwk.kty - , x: opts.jwk.x - , y: opts.jwk.y - }; - } else if (/RS/i.test(opts.jwk.kty)) { - protectedJson.jwk = { - e: opts.jwk.e - , kty: opts.jwk.kty - , n: opts.jwk.n - }; - } else { - return Promise.reject(new Error("[acme.accounts.sign] unsupported key type '" + opts.jwk.kty + "'")); - } - - console.log('protected:'); - console.log(protectedJson); - var protected64 = BACME._jsto64( - protectedJson - ); - - // Note: this function hashes before signing so send data, not the hash - return BACME._sign({ - abstractKey: abstractKey - , payload64: payload64 - , protected64: protected64 - }); - }); -}; - -var accountId; - -BACME.accounts.set = function (opts) { - nonce = null; - return window.fetch(accountUrl, { - mode: 'cors' - , method: 'POST' - , headers: { 'Content-Type': 'application/jose+json' } - , body: JSON.stringify(opts.signedAccount) - }).then(function (resp) { - BACME._logHeaders(resp); - nonce = resp.headers.get('replay-nonce'); - accountId = resp.headers.get('location'); - console.log('Next nonce:', nonce); - console.log('Location/kid:', accountId); - - if (!resp.headers.get('content-type')) { - console.log('Body: '); - - return { kid: accountId }; - } - - return resp.json().then(function (result) { - if (/^Error/i.test(result.detail)) { - return Promise.reject(new Error(result.detail)); - } - result.kid = accountId; - BACME._logBody(result); - - return result; - }); - }); -}; - -var orderUrl; - -BACME.orders = {}; - -// identifiers = [ { type: 'dns', value: 'example.com' }, { type: 'dns', value: '*.example.com' } ] -// signedAccount -BACME.orders.sign = function (opts) { - var payload64 = BACME._jsto64({ identifiers: opts.identifiers }); - - return BACME._importKey(opts.jwk).then(function (abstractKey) { - var protected64 = BACME._jsto64( - { nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: orderUrl, kid: opts.kid } - ); - console.log('abstractKey:'); - console.log(abstractKey); - return BACME._sign({ - abstractKey: abstractKey - , payload64: payload64 - , protected64: protected64 - }).then(function (sig) { - if (!sig) { - throw new Error('sig is undefined... nonsense!'); - } - console.log('newsig', sig); - return sig; - }); - }); -}; - -var currentOrderUrl; -var authorizationUrls; -var finalizeUrl; - -BACME.orders.create = function (opts) { - nonce = null; - return window.fetch(orderUrl, { - mode: 'cors' - , method: 'POST' - , headers: { 'Content-Type': 'application/jose+json' } - , body: JSON.stringify(opts.signedOrder) - }).then(function (resp) { - BACME._logHeaders(resp); - currentOrderUrl = resp.headers.get('location'); - nonce = resp.headers.get('replay-nonce'); - console.log('Next nonce:', nonce); - - return resp.json().then(function (result) { - if (/^Error/i.test(result.detail)) { - return Promise.reject(new Error(result.detail)); - } - authorizationUrls = result.authorizations; - finalizeUrl = result.finalize; - BACME._logBody(result); - - result.url = currentOrderUrl; - return result; - }); - }); -}; - -BACME.challenges = {}; -BACME.challenges.all = function () { - var challenges = []; - - function next() { - if (!authorizationUrls.length) { - return challenges; - } - - return BACME.challenges.view().then(function (challenge) { - challenges.push(challenge); - return next(); - }); - } - - return next(); -}; -BACME.challenges.view = function () { - var authzUrl = authorizationUrls.pop(); - var token; - var challengeDomain; - var challengeUrl; - - return window.fetch(authzUrl, { - mode: 'cors' - }).then(function (resp) { - BACME._logHeaders(resp); - - return resp.json().then(function (result) { - // Note: select the challenge you wish to use - var challenge = result.challenges.slice(0).pop(); - token = challenge.token; - challengeUrl = challenge.url; - challengeDomain = result.identifier.value; - - BACME._logBody(result); - - return { - challenges: result.challenges - , expires: result.expires - , identifier: result.identifier - , status: result.status - , wildcard: result.wildcard - //, token: challenge.token - //, url: challenge.url - //, domain: result.identifier.value, - }; - }); - }); -}; - -var thumbprint; -var keyAuth; -var httpPath; -var dnsAuth; -var dnsRecord; - -BACME.thumbprint = function (opts) { - // https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk - - var accountJwk = opts.jwk; - var keys; - - if (/^EC/i.test(opts.jwk.kty)) { - keys = [ 'crv', 'kty', 'x', 'y' ]; - } else if (/^RS/i.test(opts.jwk.kty)) { - keys = [ 'e', 'kty', 'n' ]; - } - - var accountPublicStr = '{' + keys.map(function (key) { - return '"' + key + '":"' + accountJwk[key] + '"'; - }).join(',') + '}'; - - return window.crypto.subtle.digest( - { name: "SHA-256" } // SHA-256 is spec'd, non-optional - , textEncoder.encode(accountPublicStr) - ).then(function (hash) { - thumbprint = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) { - return String.fromCharCode(ch); - }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); - - console.log('Thumbprint:'); - console.log(opts); - console.log(accountPublicStr); - console.log(thumbprint); - - return thumbprint; - }); -}; - -// { token, thumbprint, challengeDomain } -BACME.challenges['http-01'] = function (opts) { - // The contents of the key authorization file - keyAuth = opts.token + '.' + opts.thumbprint; - - // Where the key authorization file goes - httpPath = 'http://' + opts.challengeDomain + '/.well-known/acme-challenge/' + opts.token; - - console.log("echo '" + keyAuth + "' > '" + httpPath + "'"); - - return { - path: httpPath - , value: keyAuth - }; -}; - -// { keyAuth } -BACME.challenges['dns-01'] = function (opts) { - console.log('opts.keyAuth for DNS:'); - console.log(opts.keyAuth); - return window.crypto.subtle.digest( - { name: "SHA-256", } - , textEncoder.encode(opts.keyAuth) - ).then(function (hash) { - dnsAuth = btoa(Array.prototype.map.call(new Uint8Array(hash), function (ch) { - return String.fromCharCode(ch); - }).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); - - dnsRecord = '_acme-challenge.' + opts.challengeDomain; - - console.log('DNS TXT Auth:'); - // The name of the record - console.log(dnsRecord); - // The TXT record value - console.log(dnsAuth); - - return { - type: 'TXT' - , host: dnsRecord - , answer: dnsAuth - }; - }); -}; - -var challengePollUrl; - -// { jwk, challengeUrl, accountId (kid) } -BACME.challenges.accept = function (opts) { - var payload64 = BACME._jsto64({}); - - return BACME._importKey(opts.jwk).then(function (abstractKey) { - var protected64 = BACME._jsto64( - { nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: opts.challengeUrl, kid: opts.accountId } - ); - return BACME._sign({ - abstractKey: abstractKey - , payload64: payload64 - , protected64: protected64 - }); - }).then(function (signedAccept) { - - nonce = null; - return window.fetch( - opts.challengeUrl - , { mode: 'cors' - , method: 'POST' - , headers: { 'Content-Type': 'application/jose+json' } - , body: JSON.stringify(signedAccept) - } - ).then(function (resp) { - BACME._logHeaders(resp); - nonce = resp.headers.get('replay-nonce'); - console.log("ACCEPT NONCE:", nonce); - - return resp.json().then(function (reply) { - challengePollUrl = reply.url; - - console.log('Challenge ACK:'); - console.log(JSON.stringify(reply)); - return reply; - }); - }); - }); -}; - -BACME.challenges.check = function (opts) { - return window.fetch(opts.challengePollUrl, { mode: 'cors' }).then(function (resp) { - BACME._logHeaders(resp); - - return resp.json().then(function (reply) { - if (/error/.test(reply.type)) { - return Promise.reject(new Error(reply.detail || reply.type)); - } - challengePollUrl = reply.url; - - BACME._logBody(reply); - - return reply; - }); - }); -}; - -var domainKeypair; -var domainJwk; - -BACME.generateKeypair = function (opts) { - var wcOpts = {}; - - // ECDSA has only the P curves and an associated bitlength - if (/^EC/i.test(opts.type)) { - wcOpts.name = 'ECDSA'; - if (/256/.test(opts.bitlength)) { - wcOpts.namedCurve = 'P-256'; - } - } - - // RSA-PSS is another option, but I don't think it's used for Let's Encrypt - // I think the hash is only necessary for signing, not generation or import - if (/^RS/i.test(opts.type)) { - wcOpts.name = 'RSASSA-PKCS1-v1_5'; - wcOpts.modulusLength = opts.bitlength; - if (opts.bitlength < 2048) { - wcOpts.modulusLength = opts.bitlength * 8; - } - wcOpts.publicExponent = new Uint8Array([0x01, 0x00, 0x01]); - wcOpts.hash = { name: "SHA-256" }; - } - var extractable = true; - return window.crypto.subtle.generateKey( - wcOpts - , extractable - , [ 'sign', 'verify' ] - ); -}; -BACME.domains = {}; -// TODO factor out from BACME.accounts.generateKeypair even more -BACME.domains.generateKeypair = function (opts) { - return BACME.generateKeypair(opts).then(function (result) { - domainKeypair = result; - - return window.crypto.subtle.exportKey( - "jwk" - , result.privateKey - ).then(function (privJwk) { - - domainJwk = privJwk; - console.log('private jwk:'); - console.log(JSON.stringify(privJwk, null, 2)); - - return privJwk; - }); - }); -}; - -// { serverJwk, domains } -BACME.orders.generateCsr = function (opts) { - return BACME._importKey(opts.serverJwk).then(function (abstractKey) { - return Promise.resolve(CSR.generate({ keypair: abstractKey.wcKeypair, domains: opts.domains })); - }); -}; - -var certificateUrl; - -// { csr, jwk, finalizeUrl, accountId } -BACME.orders.finalize = function (opts) { - var payload64 = BACME._jsto64( - { csr: opts.csr } - ); - - return BACME._importKey(opts.jwk).then(function (abstractKey) { - var protected64 = BACME._jsto64( - { nonce: nonce, alg: abstractKey.meta.alg/*'ES256'*/, url: opts.finalizeUrl, kid: opts.accountId } - ); - return BACME._sign({ - abstractKey: abstractKey - , payload64: payload64 - , protected64: protected64 - }); - }).then(function (signedFinal) { - - nonce = null; - return window.fetch( - opts.finalizeUrl - , { mode: 'cors' - , method: 'POST' - , headers: { 'Content-Type': 'application/jose+json' } - , body: JSON.stringify(signedFinal) - } - ).then(function (resp) { - BACME._logHeaders(resp); - nonce = resp.headers.get('replay-nonce'); - - return resp.json().then(function (reply) { - if (/error/.test(reply.type)) { - return Promise.reject(new Error(reply.detail || reply.type)); - } - certificateUrl = reply.certificate; - BACME._logBody(reply); - - return reply; - }); - }); - }); -}; - -BACME.orders.receive = function (opts) { - return window.fetch( - opts.certificateUrl - , { mode: 'cors' - , method: 'GET' - } - ).then(function (resp) { - BACME._logHeaders(resp); - nonce = resp.headers.get('replay-nonce'); - - return resp.text().then(function (reply) { - BACME._logBody(reply); - - return reply; - }); - }); -}; - -BACME.orders.check = function (opts) { - return window.fetch( - opts.orderUrl - , { mode: 'cors' - , method: 'GET' - } - ).then(function (resp) { - BACME._logHeaders(resp); - - return resp.json().then(function (reply) { - if (/error/.test(reply.type)) { - return Promise.reject(new Error(reply.detail || reply.type)); - } - BACME._logBody(reply); - - return reply; - }); - }); -}; - -}(window)); diff --git a/lib/csr.js b/lib/csr.js new file mode 100644 index 0000000..12834e0 --- /dev/null +++ b/lib/csr.js @@ -0,0 +1,298 @@ +// 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/. */ +(function (exports) { +'use strict'; +/*global Promise*/ + +var ASN1 = exports.ASN1; +var Enc = exports.Enc; +var PEM = exports.PEM; +var X509 = exports.x509; +var Keypairs = exports.Keypairs; + +// TODO find a way that the prior node-ish way of `module.exports = function () {}` isn't broken +var CSR = exports.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 () { + var Keypairs; + 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"); + } + + Keypairs = exports.Keypairs; + if (!exports.Keypairs) { + throw new Error("Keypairs.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." + ); + } + + 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 } */ + }); +}; + +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); + }); +}; + +// +// EC / RSA +// +CSR.request = function createCsrBodyEc(jwk, domains) { + var asn1pub; + if (/^EC/i.test(jwk.kty)) { + asn1pub = X509.packCsrEcPublicKey(jwk); + } else { + asn1pub = X509.packCsrRsaPublicKey(jwk); + } + return X509.packCsr(asn1pub, domains); +}; + +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 + , ASN1.BitStr(Enc.bufToHex(opts.signature)) + ); +}; + +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('')))))))) + ); +}; + +// 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 bas64 + var c = ASN1.parse(der); + 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', ASN1.UInt(Enc.bufToHex(sig.children[0].value)), ASN1.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]; + // TODO utf8 + 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.bufToBin(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 + return Enc.bufToBin(name.value); + }); + })[0]; + })[0]; + + return { + subject: sub + , altnames: domains + , jwk: pub + , signature: sig + }; +}; + +X509.packCsrRsaPublicKey = 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); + + // Add the CSR pub key header + return ASN1('30', ASN1('30', ASN1('06', '2a864886f70d010101'), ASN1('05')), ASN1.BitStr(asn1pub)); +}; + +X509.packCsrEcPublicKey = function (jwk) { + var ecOid = X509._oids[jwk.crv]; + if (!ecOid) { + throw new Error("Unsupported namedCurve '" + jwk.crv + "'. Supported types are " + Object.keys(X509._oids)); + } + var cmp = '04'; // 04 == x+y, 02 == x-only + var hxy = ''; + // Placeholder. I'm not even sure if compression should be supported. + if (!jwk.y) { cmp = '02'; } + hxy += Enc.base64ToHex(jwk.x); + if (jwk.y) { hxy += Enc.base64ToHex(jwk.y); } + + // 1.2.840.10045.2.1 ecPublicKey + return ASN1('30', ASN1('30', ASN1('06', '2a8648ce3d0201'), ASN1('06', ecOid)), ASN1.BitStr(cmp + hxy)); +}; +X509._oids = { + // 1.2.840.10045.3.1.7 prime256v1 + // (ANSI X9.62 named elliptic curve) (06 08 - 2A 86 48 CE 3D 03 01 07) + 'P-256': '2a8648ce3d030107' + // 1.3.132.0.34 P-384 (06 05 - 2B 81 04 00 22) + // (SEC 2 recommended EC domain secp256r1) +, 'P-384': '2b81040022' + // requires more logic and isn't a recommended standard + // 1.3.132.0.35 P-521 (06 05 - 2B 81 04 00 23) + // (SEC 2 alternate P-521) +//, 'P-521': '2B 81 04 00 23' +}; + +// don't replace the full parseBlock, if it exists +PEM.parseBlock = PEM.parseBlock || function (str) { + var der = str.split(/\n/).filter(function (line) { + return !/-----/.test(line); + }).join(''); + return { bytes: Enc.base64ToBuf(der) }; +}; + +}('undefined' === typeof window ? module.exports : window)); diff --git a/lib/keypairs.js b/lib/keypairs.js index 9d6cf3d..932bc65 100644 --- a/lib/keypairs.js +++ b/lib/keypairs.js @@ -180,24 +180,12 @@ Keypairs.signJws = function (opts) { var msg = protected64 + '.' + payload64; return Keypairs._sign(opts, msg).then(function (buf) { - /* - * This will come back into play for CSRs, but not for JOSE - if ('EC' === opts.jwk.kty) { - // ECDSA JWT signatures differ from "normal" ECDSA signatures - // https://tools.ietf.org/html/rfc7518#section-3.4 - binsig = convertIfEcdsa(binsig); - } - */ var signedMsg = { protected: protected64 , payload: payload64 , signature: Enc.bufToUrlBase64(buf) }; - console.log('Signed Base64 Msg:'); - console.log(JSON.stringify(signedMsg, null, 2)); - - console.log('msg:', msg); return signedMsg; }); } @@ -212,40 +200,6 @@ Keypairs.signJws = function (opts) { } }); }; -Keypairs._convertIfEcdsa = function (binsig) { - // should have asn1 sequence header of 0x30 - if (0x30 !== binsig[0]) { throw new Error("Impossible EC SHA head marker"); } - var index = 2; // first ecdsa "R" header byte - var len = binsig[1]; - var lenlen = 0; - // Seek length of length if length is greater than 127 (i.e. two 512-bit / 64-byte R and S values) - if (0x80 & len) { - lenlen = len - 0x80; // should be exactly 1 - len = binsig[2]; // should be <= 130 (two 64-bit SHA-512s, plus padding) - index += lenlen; - } - // should be of BigInt type - if (0x02 !== binsig[index]) { throw new Error("Impossible EC SHA R marker"); } - index += 1; - - var rlen = binsig[index]; - var bits = 32; - if (rlen > 49) { - bits = 64; - } else if (rlen > 33) { - bits = 48; - } - var r = binsig.slice(index + 1, index + 1 + rlen).toString('hex'); - var slen = binsig[index + 1 + rlen + 1]; // skip header and read length - var s = binsig.slice(index + 1 + rlen + 1 + 1).toString('hex'); - if (2 *slen !== s.length) { throw new Error("Impossible EC SHA S length"); } - // There may be one byte of padding on either - while (r.length < 2*bits) { r = '00' + r; } - while (s.length < 2*bits) { s = '00' + s; } - if (2*(bits+1) === r.length) { r = r.slice(2); } - if (2*(bits+1) === s.length) { s = s.slice(2); } - return Enc.hexToBuf(r + s); -}; Keypairs._sign = function (opts, payload) { return Keypairs._import(opts).then(function (privkey) { @@ -259,9 +213,14 @@ Keypairs._sign = function (opts, payload) { , privkey , payload ).then(function (signature) { - // convert buffer to urlsafe base64 - //return Enc.bufToUrlBase64(new Uint8Array(signature)); - return new Uint8Array(signature); + signature = new Uint8Array(signature); // ArrayBuffer -> u8 + // This will come back into play for CSRs, but not for JOSE + if ('EC' === opts.jwk.kty && /x509|asn1/i.test(opts.format)) { + return Keypairs._ecdsaJoseSigToAsn1Sig(signature); + } else { + // jose/jws/jwt + return signature; + } }); }); }; @@ -287,7 +246,6 @@ Keypairs._getName = function (opts) { return 'RSASSA-PKCS1-v1_5'; } }; - Keypairs._import = function (opts) { return Promise.resolve().then(function () { var ops; @@ -301,7 +259,6 @@ Keypairs._import = function (opts) { opts.jwk.ext = true; opts.jwk.key_ops = ops; - console.log('jwk', opts.jwk); return window.crypto.subtle.importKey( "jwk" , opts.jwk @@ -316,6 +273,30 @@ Keypairs._import = function (opts) { }); }); }; +// ECDSA JOSE / JWS / JWT signatures differ from "normal" ASN1/X509 ECDSA signatures +// https://tools.ietf.org/html/rfc7518#section-3.4 +Keypairs._ecdsaJoseSigToAsn1Sig = function (bufsig) { + // it's easier to do the manipulation in the browser with an array + bufsig = Array.from(bufsig); + var hlen = bufsig.length / 2; // should be even + var r = bufsig.slice(0, hlen); + var s = bufsig.slice(hlen); + // unpad positive ints less than 32 bytes wide + while (!r[0]) { r = r.slice(1); } + while (!s[0]) { s = s.slice(1); } + // pad (or re-pad) ambiguously non-negative BigInts, up to 33 bytes wide + if (0x80 & r[0]) { r.unshift(0); } + if (0x80 & s[0]) { s.unshift(0); } + + var len = 2 + r.length + 2 + s.length; + var head = [0x30]; + // hard code 0x80 + 1 because it won't be longer than + // two SHA512 plus two pad bytes (130 bytes <= 256) + if (len >= 0x80) { head.push(0x81); } + head.push(len); + + return Uint8Array.from(head.concat([0x02, r.length], r, [0x02, s.length], s)); +}; function setTime(time) { if ('number' === typeof time) { return time; } diff --git a/lib/keypairs.js.min2 b/lib/keypairs.js.min2 deleted file mode 100644 index bf530b8..0000000 --- a/lib/keypairs.js.min2 +++ /dev/null @@ -1,86 +0,0 @@ -/*global Promise*/ -(function (exports) { -'use strict'; - -var Keypairs = exports.Keypairs = {}; - -Keypairs._stance = "We take the stance that if you're knowledgeable enough to" - + " properly and securely use non-standard crypto then you shouldn't need Bluecrypt anyway."; -Keypairs._universal = "Bluecrypt only supports crypto with standard cross-browser and cross-platform support."; -Keypairs.generate = function (opts) { - var wcOpts = {}; - if (!opts) { - opts = {}; - } - if (!opts.kty) { - opts.kty = 'EC'; - } - - // ECDSA has only the P curves and an associated bitlength - if (/^EC/i.test(opts.kty)) { - wcOpts.name = 'ECDSA'; - if (!opts.namedCurve) { - opts.namedCurve = 'P-256'; - } - wcOpts.namedCurve = opts.namedCurve; // true for supported curves - if (/256/.test(wcOpts.namedCurve)) { - wcOpts.namedCurve = 'P-256'; - wcOpts.hash = { name: "SHA-256" }; - } else if (/384/.test(wcOpts.namedCurve)) { - wcOpts.namedCurve = 'P-384'; - wcOpts.hash = { name: "SHA-384" }; - } else { - return Promise.Reject(new Error("'" + wcOpts.namedCurve + "' is not an NIST approved ECDSA namedCurve. " - + " Please choose either 'P-256' or 'P-384'. " - + Keypairs._stance)); - } - } else if (/^RSA$/i.test(opts.kty)) { - // Support PSS? I don't think it's used for Let's Encrypt - wcOpts.name = 'RSASSA-PKCS1-v1_5'; - if (!opts.modulusLength) { - opts.modulusLength = 2048; - } - wcOpts.modulusLength = opts.modulusLength; - if (wcOpts.modulusLength >= 2048 && wcOpts.modulusLength < 3072) { - // erring on the small side... for no good reason - wcOpts.hash = { name: "SHA-256" }; - } else if (wcOpts.modulusLength >= 3072 && wcOpts.modulusLength < 4096) { - wcOpts.hash = { name: "SHA-384" }; - } else if (wcOpts.modulusLength < 4097) { - wcOpts.hash = { name: "SHA-512" }; - } else { - // Public key thumbprints should be paired with a hash of similar length, - // so anything above SHA-512's keyspace would be left under-represented anyway. - return Promise.Reject(new Error("'" + wcOpts.modulusLength + "' is not within the safe and universally" - + " acceptable range of 2048-4096. Typically you should pick 2048, 3072, or 4096, though other values" - + " divisible by 8 are allowed. " + Keypairs._stance)); - } - // TODO maybe allow this to be set to any of the standard values? - wcOpts.publicExponent = new Uint8Array([0x01, 0x00, 0x01]); - } else { - return Promise.Reject(new Error("'" + opts.kty + "' is not a well-supported key type." - + Keypairs._universal - + " Please choose either 'EC' or 'RSA' keys.")); - } - - var extractable = true; - return window.crypto.subtle.generateKey( - wcOpts - , extractable - , [ 'sign', 'verify' ] - ).then(function (result) { - return window.crypto.subtle.exportKey( - "jwk" - , result.privateKey - ).then(function (privJwk) { - // TODO remove - console.log('private jwk:'); - console.log(JSON.stringify(privJwk, null, 2)); - return { - privateKey: privJwk - }; - }); - }); -}; - -}(window)); diff --git a/lib/x509.js b/lib/x509.js index 901bb36..575b8c9 100644 --- a/lib/x509.js +++ b/lib/x509.js @@ -1,6 +1,6 @@ -'use strict'; (function (exports) { 'use strict'; + var x509 = exports.x509 = {}; var ASN1 = exports.ASN1; var Enc = exports.Enc; diff --git a/package-lock.json b/package-lock.json index 2060678..7eaa7b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,12 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@root/request": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@root/request/-/request-1.3.10.tgz", + "integrity": "sha512-GSn8dfsGp0juJyXS9k7B/DjYm7Axe85wiCHfPs30eQ+/V6p2aqey45e1czb3ZwP+iPmzWCKXahhWnZhSDIil6w==", + "dev": true + }, "accepts": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.6.tgz", diff --git a/package.json b/package.json index a29af32..e122eeb 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "author": "AJ ONeal (https://coolaj86.com/)", "license": "MPL-2.0", "devDependencies": { + "@root/request": "^1.3.10", "dig.js": "^1.3.9", "dns-suite": "^1.2.12", "express": "^4.16.4" diff --git a/server.js b/server.js index 34938da..5ae0a55 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,7 @@ var crypto = require('crypto'); //var dnsjs = require('dns-suite'); var dig = require('dig.js/dns-request'); +var request = require('util').promisify(require('@root/request')); var express = require('express'); var app = express(); @@ -10,16 +11,15 @@ var nameservers = require('dns').getServers(); var index = crypto.randomBytes(2).readUInt16BE(0) % nameservers.length; var nameserver = nameservers[index]; -app.use('/', express.static('./')); +app.use('/', express.static(__dirname)); app.use('/api', express.json()); app.get('/api/dns/:domain', function (req, res, next) { - console.log(req.params); var domain = req.params.domain; var casedDomain = domain.toLowerCase().split('').map(function (ch) { - // dns0x20 takes advantage of the fact that the binary operation for toUpperCase is - // ch = ch | 0x20; - return Math.round(Math.random()) % 2 ? ch : ch.toUpperCase(); - }).join(''); + // dns0x20 takes advantage of the fact that the binary operation for toUpperCase is + // ch = ch | 0x20; + return Math.round(Math.random()) % 2 ? ch : ch.toUpperCase(); + }).join(''); var typ = req.query.type; var query = { header: { @@ -40,60 +40,60 @@ app.get('/api/dns/:domain', function (req, res, next) { } ] }; - var opts = { + var opts = { onError: function (err) { - next(err); - } + next(err); + } , onMessage: function (packet) { - var fail0x20; + var fail0x20; - if (packet.id !== query.id) { - console.error('[SECURITY] ignoring packet for \'' + packet.question[0].name + '\' due to mismatched id'); - console.error(packet); - return; - } + if (packet.id !== query.id) { + console.error('[SECURITY] ignoring packet for \'' + packet.question[0].name + '\' due to mismatched id'); + console.error(packet); + return; + } - packet.question.forEach(function (q) { - // if (-1 === q.name.lastIndexOf(cli.casedQuery)) - if (q.name !== casedDomain) { - fail0x20 = q.name; - } - }); + packet.question.forEach(function (q) { + // if (-1 === q.name.lastIndexOf(cli.casedQuery)) + if (q.name !== casedDomain) { + fail0x20 = q.name; + } + }); - [ 'question', 'answer', 'authority', 'additional' ].forEach(function (group) { - (packet[group]||[]).forEach(function (a) { - var an = a.name; - var i = domain.toLowerCase().lastIndexOf(a.name.toLowerCase()); // answer is something like ExAMPle.cOM and query was wWw.ExAMPle.cOM - var j = a.name.toLowerCase().lastIndexOf(domain.toLowerCase()); // answer is something like www.ExAMPle.cOM and query was ExAMPle.cOM + [ 'question', 'answer', 'authority', 'additional' ].forEach(function (group) { + (packet[group]||[]).forEach(function (a) { + var an = a.name; + var i = domain.toLowerCase().lastIndexOf(a.name.toLowerCase()); // answer is something like ExAMPle.cOM and query was wWw.ExAMPle.cOM + var j = a.name.toLowerCase().lastIndexOf(domain.toLowerCase()); // answer is something like www.ExAMPle.cOM and query was ExAMPle.cOM - // it's important to note that these should only relpace changes in casing that we expected - // any abnormalities should be left intact to go "huh?" about - // TODO detect abnormalities? - if (-1 !== i) { - // "EXamPLE.cOm".replace("wWw.EXamPLE.cOm".substr(4), "www.example.com".substr(4)) - a.name = a.name.replace(casedDomain.substr(i), domain.substr(i)); - } else if (-1 !== j) { - // "www.example.com".replace("EXamPLE.cOm", "example.com") - a.name = a.name.substr(0, j) + a.name.substr(j).replace(casedDomain, domain); - } + // it's important to note that these should only relpace changes in casing that we expected + // any abnormalities should be left intact to go "huh?" about + // TODO detect abnormalities? + if (-1 !== i) { + // "EXamPLE.cOm".replace("wWw.EXamPLE.cOm".substr(4), "www.example.com".substr(4)) + a.name = a.name.replace(casedDomain.substr(i), domain.substr(i)); + } else if (-1 !== j) { + // "www.example.com".replace("EXamPLE.cOm", "example.com") + a.name = a.name.substr(0, j) + a.name.substr(j).replace(casedDomain, domain); + } - // NOTE: right now this assumes that anything matching the query matches all the way to the end - // it does not handle the case of a record for example.com.uk being returned in response to a query for www.example.com correctly - // (but I don't think it should need to) - if (a.name.length !== an.length) { - console.error("[ERROR] question / answer mismatch: '" + an + "' != '" + a.length + "'"); - console.error(a); - } - }); - }); + // NOTE: right now this assumes that anything matching the query matches all the way to the end + // it does not handle the case of a record for example.com.uk being returned in response to a query for www.example.com correctly + // (but I don't think it should need to) + if (a.name.length !== an.length) { + console.error("[ERROR] question / answer mismatch: '" + an + "' != '" + a.length + "'"); + console.error(a); + } + }); + }); - if (fail0x20) { - console.warn(";; Warning: DNS 0x20 security not implemented (or packet spoofed). Queried '" - + casedDomain + "' but got response for '" + fail0x20 + "'."); - return; - } + if (fail0x20) { + console.warn(";; Warning: DNS 0x20 security not implemented (or packet spoofed). Queried '" + + casedDomain + "' but got response for '" + fail0x20 + "'."); + return; + } - res.send({ + res.send({ header: packet.header , question: packet.question , answer: packet.answer @@ -101,12 +101,12 @@ app.get('/api/dns/:domain', function (req, res, next) { , additional: packet.additional , edns_options: packet.edns_options }); - } + } , onListening: function () {} , onSent: function (/*res*/) { } , onTimeout: function (res) { - console.error('dns timeout:', res); - next(new Error("DNS timeout - no response")); + console.error('dns timeout:', res); + next(new Error("DNS timeout - no response")); } , onClose: function () { } //, mdns: cli.mdns @@ -117,9 +117,23 @@ app.get('/api/dns/:domain', function (req, res, next) { dig.resolveJson(query, opts); }); +app.get('/api/http', function (req, res) { + var url = req.query.url; + return request({ method: 'GET', url: url }).then(function (resp) { + res.send(resp.body); + }); +}); +app.get('/api/_acme_api_', function (req, res) { + res.send({ success: true }); +}); -// curl -L http://localhost:3000/api/dns/example.com?type=A -console.log("Listening on localhost:3000"); -app.listen(3000); -console.log("Try this:"); -console.log("\tcurl -L 'http://localhost:3000/api/dns/example.com?type=A'"); +module.exports = app; +if (require.main === module) { + // curl -L http://localhost:3000/api/dns/example.com?type=A + console.info("Listening on localhost:3000"); + app.listen(3000); + console.info("Try this:"); + console.info("\tcurl -L 'http://localhost:3000/api/_acme_api_/'"); + console.info("\tcurl -L 'http://localhost:3000/api/dns/example.com?type=A'"); + console.info("\tcurl -L 'http://localhost:3000/api/http/?url=https://example.com'"); +}