(function() { "use strict"; /*global URLSearchParams,Headers*/ var PromiseA = window.Promise; 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 = false; 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 info = {}; var steps = {}; var i = 1; var apiUrl = "https://acme-{{env}}.api.letsencrypt.org/directory"; // fix previous browsers var isCurrent = localStorage.getItem("version") === VERSION; if (!isCurrent) { localStorage.clear(); localStorage.setItem("version", VERSION); } localStorage.setItem("version", VERSION); function updateApiType() { /*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 newAlert(str) { return new Promise(function() { setTimeout(function() { window.alert(str); if (window.confirm("Start over?")) { document.location.href = document.location.href.replace( /\/app.*/, "/" ); } }, 10); }); } function submitForm(ev) { var j = i; i += 1; return PromiseA.resolve() .then(function() { return steps[j].submit(ev); }) .catch(function(err) { var ourfault = true; console.error(err); if (/failed to fetch/i.test(err.message)) { return newAlert("Network connection failure."); } if ("E_ACME_CHALLENGE" === err.code) { if ("dns-01" === err.type) { ourfault = false; return newAlert( "It looks like the DNS record you set for " + err.altname + " was incorrect or did not propagate. " + "The error message was '" + err.message + "'" ); } else if ("http-01" === err.type) { ourfault = false; return newAlert( "It looks like the file you uploaded for " + err.altname + " was incorrect or could not be downloaded. " + "The error message was '" + err.message + "'" ); } } if (ourfault) { err.auth = undefined; window.alert( "Something went wrong. It's probably our fault, not yours." + " Please email aj@rootprojects.org to let him know. The error message is: \n" + JSON.stringify(err, null, 2) ); return new Promise(function() {}); } }); } function testKeypairSupport() { return Keypairs.generate(RSA_OPTS) .then(function() { console.info("[crypto] RSA is supported"); BROWSER_SUPPORTS_RSA = true; }) .catch(function() { console.warn("[crypto] RSA is NOT supported"); return Keypairs.generate(ECDSA_OPTS) .then(function() { console.info("[crypto] ECDSA is supported"); }) .catch(function(e) { console.warn("[crypto] EC is NOT supported"); throw e; }); }); } 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]; $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() { console.info("\n1. Show domains form"); updateProgress(0); hideForms(); $qs(".js-acme-form-domains").hidden = false; }; steps[1].submit = function() { console.info( "[submit] 1. Process domains, create ACME client", info.domains ); info.domains = $qs(".js-acme-domains") .value.replace(/https?:\/\//g, " ") .replace(/[,+]/g, " ") .trim() .split(/\s+/g); console.info("[domains]", info.domains.join(" ")); info.identifiers = info.domains.map(function(hostname) { return { type: "dns", value: hostname.toLowerCase().trim() }; }); 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; return steps[i](); }); }; steps[2] = function() { console.info("\n2. Show account (email, ToS) form"); updateProgress(0); hideForms(); $qs(".js-acme-form-account").hidden = false; }; steps[2].submit = function() { console.info("[submit] 2. Create ACME account (get Key ID)"); var email = $qs(".js-acme-account-email") .value.toLowerCase() .trim(); info.email = email; 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.domains); $qs(".js-account-next").disabled = true; return info.cryptoCheck .then(function() { return getAccountKeypair(email).then(function(jwk) { // TODO save account id rather than always retrieving it? console.info("[accounts] upsert for", email); return acme.accounts .create({ email: email, agreeToTerms: info.agree && true, accountKeypair: { privateKeyJwk: jwk } }) .then(function(account) { console.info("[accounts] result:", account); info.account = account; info.privateJwk = jwk; info.email = email; }) .catch(function(err) { console.error("[accounts] failed to upsert account:"); console.error(err); return newAlert(err.message || JSON.stringify(err, null, 2)); }); }); }) .then(function() { var jwk = info.privateJwk; var account = info.account; console.info("[orders] requesting"); return acme.orders .request({ account: account, accountKeypair: { privateKeyJwk: jwk }, domains: info.domains }) .then(function(order) { info.order = order; console.info("[orders] created ", order); var claims = order.claims; var obj = { "dns-01": [], "http-01": [], wildcard: [] }; info.challenges = obj; var $httpList = $qs(".js-acme-http"); var $dnsList = $qs(".js-acme-dns"); var $wildList = $qs(".js-acme-wildcard"); var httpTpl = $httpList.innerHTML; var dnsTpl = $dnsList.innerHTML; var wildTpl = $wildList.innerHTML; $httpList.innerHTML = ""; $dnsList.innerHTML = ""; $wildList.innerHTML = ""; claims.forEach(function(claim) { //#console.log("claims[i]", claim); var hostname = claim.identifier.value; claim.challenges.forEach(function(c) { var auth = c; var data = { type: c.type, hostname: hostname, url: c.url, token: c.token, httpPath: auth.challengeUrl, httpAuth: auth.keyAuthorization, dnsType: "TXT", dnsHost: auth.dnsHost, dnsAnswer: auth.keyAuthorizationDigest }; //#console.log("claims[i].challenge", data); var $tpl = document.createElement("div"); if (claim.wildcard) { obj.wildcard.push(data); $tpl.innerHTML = wildTpl; $tpl.querySelector(".js-acme-ver-txt-host").innerHTML = data.dnsHost; $tpl.querySelector(".js-acme-ver-txt-value").innerHTML = data.dnsAnswer; $wildList.appendChild($tpl); } else if (obj[data.type]) { obj[data.type].push(data); if ("dns-01" === data.type) { $tpl.innerHTML = dnsTpl; $tpl.querySelector(".js-acme-ver-txt-host").innerHTML = data.dnsHost; $tpl.querySelector(".js-acme-ver-txt-value").innerHTML = data.dnsAnswer; $dnsList.appendChild($tpl); } else if ("http-01" === data.type) { $tpl.innerHTML = httpTpl; $tpl.querySelector( ".js-acme-ver-file-location" ).innerHTML = data.httpPath.split("/").slice(-1); $tpl.querySelector(".js-acme-ver-content").innerHTML = data.httpAuth; $tpl.querySelector(".js-acme-ver-uri").innerHTML = data.httpPath; $tpl.querySelector(".js-download-verify-link").href = "data:text/octet-stream;base64," + window.btoa(data.httpAuth); $tpl.querySelector( ".js-download-verify-link" ).download = data.httpPath.split("/").slice(-1); $httpList.appendChild($tpl); } } }); }); // 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; } console.info("[housekeeping] challenges", info.challenges); updateChallengeType(); return steps[i](); }) .catch(function(err) { if (err.detail || err.urn) { console.error("(Probably) User Error:"); console.error(err); return newAlert( "There was an error, probably with your email or domain:\n" + err.message ); } throw err; }); }) .catch(function(err) { console.error("Step '' Error:"); console.error(err, err.stack); return newAlert( "An error happened (but it's not your fault)." + " Email aj@rootprojects.org to let him know that 'order and get challenges' failed." ); }); }; steps[3] = function() { console.info("\n3. Present challenge options"); updateProgress(1); hideForms(); $qs(".js-acme-form-challenges").hidden = false; }; steps[3].submit = function() { console.info("[submit] 3. Fulfill challenges, fetch certificate"); var challengePriority = ["dns-01"]; if ("http-01" === $qs(".js-acme-challenge-type:checked").value) { challengePriority.unshift("http-01"); } console.info("[challenge] selected ", challengePriority[0]); // for now just show the next page immediately (its a spinner) steps[i](); return getAccountKeypair(info.email).then(function(jwk) { // TODO put a test challenge in the list // info.order.claims.push(...) // TODO warn about wait-time if DNS return getServerKeypair().then(function(serverJwk) { return acme.orders .complete({ account: info.account, accountKeypair: { privateKeyJwk: jwk }, order: info.order, domains: info.domains, domainKeypair: { privateKeyJwk: serverJwk }, challengePriority: challengePriority, challenges: false, onChallengeStatus: function(details) { $qs(".js-challenge-responses").hidden = false; $qs(".js-challenge-response-type").innerText = details.type; $qs(".js-challenge-response-status").innerText = details.status; $qs(".js-challenge-response-altname").innerText = details.altname; } }) .then(function(certs) { return Keypairs.export({ jwk: serverJwk }).then(function(keyPem) { console.info("WINNING!"); console.info(certs); var fullChainText = [ certs.cert.trim() + "\n", certs.chain + "\n" ].join("\n"); $qs("#js-fullchain").innerHTML = fullChainText; $qs("#js-download-fullchain-link").href = "data:text/octet-stream;base64," + window.btoa(fullChainText); $qs("#js-privkey").innerHTML = keyPem; $qs("#js-download-privkey-link").href = "data:text/octet-stream;base64," + window.btoa(keyPem); return submitForm(); }); }); }); }); }; // spinner steps[4] = function() { console.info("\n4. Show loading spinner"); updateProgress(1); hideForms(); $qs(".js-acme-form-poll").hidden = false; }; steps[4].submit = function() { console.info("[submit] 4. Order complete"); return steps[i](); }; steps[5] = function() { console.info("\n5. Present certificates (yay!)"); updateProgress(2); hideForms(); $qs(".js-acme-form-download").hidden = false; }; function init() { $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(); return submitForm(ev); }); }); $qsa(".js-acme-challenge-type").forEach(function($el) { $el.addEventListener("change", updateChallengeType); }); var params = new URLSearchParams(window.location.search); var apiType = params.get("acme-api-type") || "staging-v02"; if (params.has("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](); return submitForm(); } else { steps[1](); } } init(); $qs("body").hidden = false; // in the background info.cryptoCheck = testKeypairSupport() .then(function() { console.info("[crypto] self-check: passed"); }) .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; }); })();