505 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			505 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| (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);
 | |
|             $qs('#js-fullchain').innerHTML = [
 | |
|               certs.cert.trim() + "\n"
 | |
|             , certs.chain + "\n"
 | |
|             ].join("\n");
 | |
|             $qs("#js-download-fullchain-link").href =
 | |
|               "data:text/octet-stream;base64," + window.btoa(certs);
 | |
| 
 | |
|             $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;
 | |
|   });
 | |
| }());
 |