495 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			495 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| (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;
 | |
|     });
 | |
|   });
 | |
| }());
 |