From 11020cbf277d2e1f29119aa7e1ce1fbb956a8369 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 19 May 2019 01:10:49 -0600 Subject: [PATCH] WIP: moving to bluecrypt --- app/js/greenlock.js | 494 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 app/js/greenlock.js diff --git a/app/js/greenlock.js b/app/js/greenlock.js new file mode 100644 index 0000000..a80ab03 --- /dev/null +++ b/app/js/greenlock.js @@ -0,0 +1,494 @@ +(function () { +'use strict'; + + /*global URLSearchParams,Headers*/ + var VERSION = '2'; + // ACME recommends ECDSA P-256, but RSA 2048 is still required by some old servers (like what replicated.io uses ) + // ECDSA P-384, P-521, and RSA 3072, 4096 are NOT recommend standards (and not properly supported) + var BROWSER_SUPPORTS_RSA; + var ECDSA_OPTS = { kty: 'EC', namedCurve: 'P-256' }; + var RSA_OPTS = { kty: 'RSA', modulusLength: 2048 }; + var Promise = window.Promise; + var Keypairs = window.Keypairs; + var ACME = window.ACME; + var CSR = window.CSR; + var $qs = function (s) { return window.document.querySelector(s); }; + var $qsa = function (s) { return window.document.querySelectorAll(s); }; + var acme; + var accountStuff; + var info = {}; + var steps = {}; + var i = 1; + var apiUrl = 'https://acme-{{env}}.api.letsencrypt.org/directory'; + + function updateApiType() { + console.log("type updated"); + /*jshint validthis: true */ + var input = this || Array.prototype.filter.call( + $qsa('.js-acme-api-type'), function ($el) { return $el.checked; } + )[0]; + console.log('ACME api type radio:', input.value); + $qs('.js-acme-directory-url').value = apiUrl.replace(/{{env}}/g, input.value); + } + + function hideForms() { + $qsa('.js-acme-form').forEach(function (el) { + el.hidden = true; + }); + } + + function updateProgress(currentStep) { + var progressSteps = $qs("#js-progress-bar").children; + var j; + for (j = 0; j < progressSteps.length; j += 1) { + if (j < currentStep) { + progressSteps[j].classList.add("js-progress-step-complete"); + progressSteps[j].classList.remove("js-progress-step-started"); + } else if (j === currentStep) { + progressSteps[j].classList.remove("js-progress-step-complete"); + progressSteps[j].classList.add("js-progress-step-started"); + } else { + progressSteps[j].classList.remove("js-progress-step-complete"); + progressSteps[j].classList.remove("js-progress-step-started"); + } + } + } + + function submitForm(ev) { + var j = i; + i += 1; + + return PromiseA.resolve(steps[j].submit(ev)).catch(function (err) { + console.error(err); + window.alert("Something went wrong. It's our fault not yours. Please email aj@rootprojects.org and let him know that 'step " + j + "' failed."); + }); + } + + function testEcdsaSupport() { + /* + var opts = { + kty: $('input[name="kty"]:checked').value + , namedCurve: $('input[name="ec-crv"]:checked').value + , modulusLength: $('input[name="rsa-len"]:checked').value + }; + */ + } + function testRsaSupport() { + return Keypairs.generate(RSA_OPTS); + } + function testKeypairSupport() { + // fix previous browsers + var isCurrent = (localStorage.getItem('version') === VERSION); + if (!isCurrent) { + localStorage.clear(); + localStorage.setItem('version', VERSION); + } + localStorage.setItem('version', VERSION); + + return testRsaSupport().then(function () { + console.info("[crypto] RSA is supported"); + BROWSER_SUPPORTS_RSA = true; + return BROWSER_SUPPORTS_RSA; + }).catch(function () { + console.warn("[crypto] RSA is NOT fully supported"); + BROWSER_SUPPORTS_RSA = false; + return BROWSER_SUPPORTS_RSA; + }); + } + + function getServerKeypair() { + var sortedAltnames = info.identifiers.map(function (ident) { return ident.value; }).sort().join(','); + var serverJwk = JSON.parse(localStorage.getItem('server:' + sortedAltnames) || 'null'); + if (serverJwk) { + return PromiseA.resolve(serverJwk); + } + + var keypairOpts; + // TODO allow for user preference + if (BROWSER_SUPPORTS_RSA) { + keypairOpts = RSA_OPTS; + } else { + keypairOpts = ECDSA_OPTS; + } + + return Keypairs.generate(RSA_OPTS).catch(function (err) { + console.error("[Error] Keypairs.generate(" + JSON.stringify(RSA_OPTS) + "):"); + throw err; + }).then(function (pair) { + localStorage.setItem('server:'+sortedAltnames, JSON.stringify(pair.private)); + return pair.private; + }); + } + + function getAccountKeypair(email) { + var json = localStorage.getItem('account:'+email); + if (json) { + return Promise.resolve(JSON.parse(json)); + } + + return Keypairs.generate(ECDSA_OPTS).catch(function (err) { + console.warn("[Error] Keypairs.generate(" + JSON.stringify(ECDSA_OPTS) + "):\n", err); + return Keypairs.generate(RSA_OPTS).catch(function (err) { + console.error("[Error] Keypairs.generate(" + JSON.stringify(RSA_OPTS) + "):"); + throw err; + }); + }).then(function (pair) { + localStorage.setItem('account:'+email, JSON.stringify(pair.private)); + return pair.private; + }); + } + + function updateChallengeType() { + /*jshint validthis: true*/ + var input = this || Array.prototype.filter.call( + $qsa('.js-acme-challenge-type'), function ($el) { return $el.checked; } + )[0]; + console.log('ch type radio:', input.value); + $qs('.js-acme-verification-wildcard').hidden = true; + $qs('.js-acme-verification-http-01').hidden = true; + $qs('.js-acme-verification-dns-01').hidden = true; + if (info.challenges.wildcard) { + $qs('.js-acme-verification-wildcard').hidden = false; + } + if (info.challenges[input.value]) { + $qs('.js-acme-verification-' + input.value).hidden = false; + } + } + + function saveContact(email, domains) { + // to be used for good, not evil + return window.fetch('https://api.rootprojects.org/api/rootprojects.org/public/community', { + method: 'POST' + , cors: true + , headers: new Headers({ 'Content-Type': 'application/json' }) + , body: JSON.stringify({ + address: email + , project: 'greenlock-domains@rootprojects.org' + , timezone: new Intl.DateTimeFormat().resolvedOptions().timeZone + , domain: domains.join(',') + }) + }).catch(function (err) { + console.error(err); + }); + } + + steps[1] = function () { + updateProgress(0); + hideForms(); + $qs('.js-acme-form-domains').hidden = false; + }; + steps[1].submit = function () { + info.identifiers = $qs('.js-acme-domains').value.split(/\s*,\s*/g).map(function (hostname) { + return { type: 'dns', value: hostname.toLowerCase().trim() }; + }).slice(0,1); //Disable multiple values for now. We'll just take the first and work with it. + info.identifiers.sort(function (a, b) { + if (a === b) { return 0; } + if (a < b) { return 1; } + if (a > b) { return -1; } + }); + + var acmeDirectoryUrl = $qs('.js-acme-directory-url').value; + acme = ACME.create({ Keypairs: Keypairs, CSR: CSR }); + return acme.init(acmeDirectoryUrl).then(function (directory) { + $qs('.js-acme-tos-url').href = directory.meta.termsOfService; + console.log("MAGIC STEP NUMBER in 1 is:", i); + steps[i](); + }); + }; + + steps[2] = function () { + updateProgress(0); + hideForms(); + $qs('.js-acme-form-account').hidden = false; + }; + steps[2].submit = function () { + var email = $qs('.js-acme-account-email').value.toLowerCase().trim(); + + info.contact = [ 'mailto:' + email ]; + info.agree = $qs('.js-acme-account-tos').checked; + //info.greenlockAgree = $qs('.js-gl-tos').checked; + + // TODO ping with version and account creation + setTimeout(saveContact, 100, email, info.identifiers.map(function (ident) { return ident.value; })); + + function checkTos(tos) { + if (info.agree) { + return tos; + } else { + return ''; + } + } + + return getAccountKeypair(email).then(function (jwk) { + // TODO save account id rather than always retrieving it? + return acme.accounts.create({ + email: email + , agreeToTerms: checkTos + , accountKeypair: { privateKeyJwk: jwk } + }).then(function (account) { + console.log("account created result:", account); + accountStuff.account = account; + accountStuff.privateJwk = jwk; + accountStuff.email = email; + accountStuff.acme = acme; // TODO XXX remove + }).catch(function (err) { + console.error("A bad thing happened:"); + console.error(err); + window.alert(err.message || JSON.stringify(err, null, 2)); + return new Promise(function () { + // stop the process cold + console.warn('TODO: resume at ask email?'); + }); + }); + }).then(function () { + var jwk = accountStuff.privateJwk; + var account = accountStuff.account; + + return acme.orders.create({ + account: account + , accountKeypair: { privateKeyJwk: jwk } + , identifiers: info.identifiers + }).then(function (order) { + return acme.orders.create({ + signedOrder: signedOrder + }).then(function (order) { + accountStuff.order = order; + var claims = order.challenges; + console.log('claims:'); + console.log(claims); + + var obj = { 'dns-01': [], 'http-01': [], 'wildcard': [] }; + info.challenges = obj; + var map = { + 'http-01': '.js-acme-verification-http-01' + , 'dns-01': '.js-acme-verification-dns-01' + , 'wildcard': '.js-acme-verification-wildcard' + }; + options.challengePriority = [ 'http-01', 'dns-01' ]; + + // TODO make Promise-friendly + return PromiseA.all(claims.map(function (claim) { + var hostname = claim.identifier.value; + return PromiseA.all(claim.challenges.map(function (c) { + var keyAuth = BACME.challenges['http-01']({ + token: c.token + , thumbprint: thumbprint + , challengeDomain: hostname + }); + return BACME.challenges['dns-01']({ + keyAuth: keyAuth.value + , challengeDomain: hostname + }).then(function (dnsAuth) { + var data = { + type: c.type + , hostname: hostname + , url: c.url + , token: c.token + , keyAuthorization: keyAuth + , httpPath: keyAuth.path + , httpAuth: keyAuth.value + , dnsType: dnsAuth.type + , dnsHost: dnsAuth.host + , dnsAnswer: dnsAuth.answer + }; + + console.log(''); + console.log('CHALLENGE'); + console.log(claim); + console.log(c); + console.log(data); + console.log(''); + + if (claim.wildcard) { + obj.wildcard.push(data); + let verification = $qs(".js-acme-verification-wildcard"); + verification.querySelector(".js-acme-ver-hostname").innerHTML = data.hostname; + verification.querySelector(".js-acme-ver-txt-host").innerHTML = data.dnsHost; + verification.querySelector(".js-acme-ver-txt-value").innerHTML = data.dnsAnswer; + + } else if(obj[data.type]) { + + obj[data.type].push(data); + + if ('dns-01' === data.type) { + let verification = $qs(".js-acme-verification-dns-01"); + verification.querySelector(".js-acme-ver-hostname").innerHTML = data.hostname; + verification.querySelector(".js-acme-ver-txt-host").innerHTML = data.dnsHost; + verification.querySelector(".js-acme-ver-txt-value").innerHTML = data.dnsAnswer; + } else if ('http-01' === data.type) { + $qs(".js-acme-ver-file-location").innerHTML = data.httpPath.split("/").slice(-1); + $qs(".js-acme-ver-content").innerHTML = data.httpAuth; + $qs(".js-acme-ver-uri").innerHTML = data.httpPath; + $qs(".js-download-verify-link").href = + "data:text/octet-stream;base64," + window.btoa(data.httpAuth); + $qs(".js-download-verify-link").download = data.httpPath.split("/").slice(-1); + } + } + + }); + + })); + })).then(function () { + + // hide wildcard if no wildcard + // hide http-01 and dns-01 if only wildcard + if (!obj.wildcard.length) { + $qs('.js-acme-wildcard-challenges').hidden = true; + } + if (!obj['http-01'].length) { + $qs('.js-acme-challenges').hidden = true; + } + + updateChallengeType(); + + console.log("MAGIC STEP NUMBER in 2 is:", i); + steps[i](); + }); + + }); + }); + }).catch(function (err) { + console.error('Step \'\' Error:'); + console.error(err, err.stack); + window.alert("An error happened at Step " + i + ", but it's not your fault. Email aj@rootprojects.org and let him know."); + }); + }; + + steps[3] = function () { + updateProgress(1); + hideForms(); + $qs('.js-acme-form-challenges').hidden = false; + }; + steps[3].submit = function () { + options.challengeTypes = [ 'dns-01' ]; + if ('http-01' === $qs('.js-acme-challenge-type:checked').value) { + options.challengeTypes.unshift('http-01'); + } + console.log('primary challenge type is:', options.challengeTypes[0]); + + return getAccountKeypair(email).then(function (jwk) { + // for now just show the next page immediately (its a spinner) + // TODO put a test challenge in the list + // TODO warn about wait-time if DNS + steps[i](); + return getServerKeypair().then(function () { + return acme.orders.finalize({ + account: accountStuff.account + , accountKeypair: { privateKeyJwk: jwk } + , order: accountStuff.order + , domainKeypair: 'TODO' + }); + }).then(function (certs) { + console.log('WINNING!'); + console.log(certs); + $qs('#js-fullchain').innerHTML = certs; + $qs("#js-download-fullchain-link").href = + "data:text/octet-stream;base64," + window.btoa(certs); + + var wcOpts; + var pemName; + if (/^R/.test(info.serverJwk.kty)) { + pemName = 'RSA'; + wcOpts = { name: "RSASSA-PKCS1-v1_5", hash: { name: "SHA-256" } }; + } else { + pemName = 'EC'; + wcOpts = { name: "ECDSA", namedCurve: "P-256" }; + } + return crypto.subtle.importKey( + "jwk" + , info.serverJwk + , wcOpts + , true + , ["sign"] + ).then(function (privateKey) { + return window.crypto.subtle.exportKey("pkcs8", privateKey); + }).then (function (keydata) { + var pem = spkiToPEM(keydata, pemName); + $qs('#js-privkey').innerHTML = pem; + $qs("#js-download-privkey-link").href = + "data:text/octet-stream;base64," + window.btoa(pem); + steps[i](); + }); + }); + }).then(function () { + return submitForm(); + }); + }; + + // spinner + steps[4] = function () { + updateProgress(1); + hideForms(); + $qs('.js-acme-form-poll').hidden = false; + }; + steps[4].submit = function () { + console.log('Congrats! Auto advancing...'); + + + }).catch(function (err) { + console.error(err.toString()); + window.alert("An error happened in the final step, but it's not your fault. Email aj@rootprojects.org and let him know."); + }); + }; + + steps[5] = function () { + updateProgress(2); + hideForms(); + $qs('.js-acme-form-download').hidden = false; + }; + steps[1](); + + var params = new URLSearchParams(window.location.search); + var apiType = params.get('acme-api-type') || "staging-v02"; + + $qsa('.js-acme-api-type').forEach(function ($el) { + $el.addEventListener('change', updateApiType); + }); + + updateApiType(); + + $qsa('.js-acme-form').forEach(function ($el) { + $el.addEventListener('submit', function (ev) { + ev.preventDefault(); + submitForm(ev); + }); + }); + + + $qsa('.js-acme-challenge-type').forEach(function ($el) { + $el.addEventListener('change', updateChallengeType); + }); + + if(params.has('acme-domains')) { + console.log("acme-domains param: ", params.get('acme-domains')); + $qs('.js-acme-domains').value = params.get('acme-domains'); + + $qsa('.js-acme-api-type').forEach(function(ele) { + if(ele.value === apiType) { + ele.checked = true; + } + }); + + updateApiType(); + steps[2](); + submitForm(); + } + + $qs('body').hidden = false; + + return testKeypairSupport().then(function (rsaSupport) { + if (rsaSupport) { + return true; + } + + return testRsaSupport().then(function () { + console.info('[crypto] RSA is supported'); + }).catch(function (err) { + console.error('[crypto] could not use either RSA nor EC.'); + console.error(err); + window.alert("Generating secure certificates requires a browser with cryptography support." + + "Please consider a recent version of Chrome, Firefox, or Safari."); + throw err; + }); + }); +}());