Source for Greenlock Domains website.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

609 lines
17 KiB

(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;
});
})();