greenlock.html/app/js/greenlock.js

480 lines
16 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 submitForm(ev) {
var j = i;
i += 1;
return PromiseA.resolve(steps[j].submit(ev)).catch(function (err) {
var ourfault = true;
console.error(err);
console.error(Object.keys(err));
if ('E_CHALLENGE_INVALID' === err.code) {
if ('dns-01' === err.type) {
ourfault = false;
window.alert("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;
window.alert("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));
}
});
}
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;
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);
window.alert(err.message || JSON.stringify(err, null, 2));
return new Promise(function () {
if (window.confirm("Start over?")) {
document.location.reload();
}
});
});
});
}).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();
steps[i]();
});
}).catch(function (err) {
console.error('Step \'\' Error:');
console.error(err, err.stack);
window.alert("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
}).then(function (certs) {
return Keypairs.export({ jwk: serverJwk }).then(function (keyPem) {
console.info('WINNING!');
console.log(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);
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');
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();
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]();
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;
});
}());