Added key rollover code.

This commit is contained in:
Ashiq5 2020-11-04 20:25:25 +06:00
parent 94aab7c5e2
commit e6657d6ebe
6 changed files with 228 additions and 29 deletions

View File

@ -364,6 +364,77 @@ def ssl_get_csr(domain):
ssl_private_key = os.path.join(os.path.join(env["STORAGE_ROOT"], 'ssl', 'ssl_private_key.pem'))
return create_csr(domain, ssl_private_key, request.form.get('countrycode', ''), env)
@app.route('/ssl/renew/<domain>', methods=['POST'])
@authorized_personnel_only
def ssl_renew(domain):
from exclusiveprocess import Lock
from utils import load_environment
from ssl_certificates import provision_certificates
existing_key = request.form.get('existing_key')
Lock(die=True).forever()
env = load_environment()
if existing_key == "yes":
status = provision_certificates(env, limit_domains=[], domain_to_be_renewed=domain)
app.logger.warning("renew without new key=", status) # TODO: remove this line after testing
elif existing_key == "no":
import glob
try:
# steps followed
# 1. take a backup of the current /home/user-data/ssl/ folder to be safe
# 2. renew all the existing certificates from CSR generated from the existing next_ssl_private_key
# 3. if the renew is successful, replace the current ssl_private_key with the next_ssl_private_key and
# 4. generate the next_ssl_private_key
# 5. if any error occurs, copy everything from the /home/user-data/ssl-backup folder to /home/user-data/ssl
# step 1
files = glob.glob(env["STORAGE_ROOT"] + "/ssl/*")
for file in files:
subprocess.check_output(["cp", "-r", file, env["STORAGE_ROOT"] + "/ssl-backup/"])
# step 2
status = provision_certificates(env, limit_domains=[], new_key=True)
# step 3 and 4 is in post_install_func method of ssl_certificates.py
app.logger.warning("renew with new key=", status) # TODO: remove this line after proper testing
except Exception as e:
import traceback
files = glob.glob(env["STORAGE_ROOT"] + "/ssl-backup/*")
for file in files:
subprocess.check_output(["cp", "-r", file, env["STORAGE_ROOT"] + "/ssl/"])
app.logger.warning(traceback.print_exc()) # TODO: remove this line after proper testing
return json_response({
"title": "Error",
"log": "Sorry, something is not right!",
})
else:
return json_response({
"title": "Error",
"log": "Sorry, something is not right!",
})
for item in status:
if isinstance(status, str):
continue
else:
if domain in item['domains']:
if item['result'] == 'skipped':
return json_response({
"title": item["result"].capitalize(),
"log": "\n".join(item['log']),
})
elif item['result'] == 'installed':
return json_response({
"title": item["result"].capitalize(),
"log": "Your certificate containing these domains " + ",".join(
item['domains']) + " have been renewed",
})
else:
return json_response({
"title": item["result"].capitalize(),
"log": "\n".join(item['log'])
})
@app.route('/ssl/install', methods=['POST'])
@authorized_personnel_only
def ssl_install_cert():
@ -604,7 +675,8 @@ def log_failed_login(request):
# APP
if __name__ == '__main__':
if "DEBUG" in os.environ: app.debug = True
app.debug = True # TODO: remove this line and uncomment the next line after testing
# if "DEBUG" in os.environ: app.debug = True
if "APIKEY" in os.environ: auth_service.key = os.environ["APIKEY"]
if not app.debug:

View File

@ -174,9 +174,11 @@ def build_zone(domain, all_domains, additional_records, www_redirect_domains, en
# Add a DANE TLSA record for SMTP.
records.append(("_25._tcp", "TLSA", build_tlsa_record(env), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used."))
records.append(("_25._tcp", "TLSA", build_tlsa_record(env, from_cert=False), "Recommended when DNSSEC is enabled. Advertises to mail servers connecting to the box that mandatory encryption should be used."))
# Add a DANE TLSA record for HTTPS, which some browser extensions might make use of.
records.append(("_443._tcp", "TLSA", build_tlsa_record(env), "Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it."))
records.append(("_443._tcp", "TLSA", build_tlsa_record(env, from_cert=False), "Optional. When DNSSEC is enabled, provides out-of-band HTTPS certificate validation for a few web clients that support it."))
# Add a SSHFP records to help SSH key validation. One per available SSH key on this system.
for value in build_sshfp_records():
@ -367,7 +369,7 @@ def build_zone(domain, all_domains, additional_records, www_redirect_domains, en
########################################################################
def build_tlsa_record(env):
def build_tlsa_record(env, from_cert=True):
# A DANE TLSA record in DNS specifies that connections on a port
# must use TLS and the certificate must match a particular criteria.
#
@ -390,11 +392,16 @@ def build_tlsa_record(env):
from ssl_certificates import load_cert_chain, load_pem
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
if from_cert:
fn = os.path.join(env["STORAGE_ROOT"], "ssl", "ssl_certificate.pem")
cert = load_pem(load_cert_chain(fn)[0])
subject_public_key = cert.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
# We could have also loaded ssl_private_key.pem and called priv_key.public_key().public_bytes(...)
else:
# this is for Double TLSA scheme of key rollover.
# More details here (https://mail.sys4.de/pipermail/dane-users/2018-February/000440.html)
fn = os.path.join(env["STORAGE_ROOT"], "ssl", "next_ssl_private_key.pem")
private_key = load_pem(open(fn, 'rb').read())
subject_public_key = private_key.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
pk_hash = hashlib.sha256(subject_public_key).hexdigest()
@ -862,6 +869,8 @@ def set_custom_dns_record(qname, rtype, value, action, env):
if not re.search(DOMAIN_RE, value):
raise ValueError("Invalid value.")
elif rtype == "TLSA":
pass
elif rtype in ("CNAME", "TXT", "SRV", "MX", "SSHFP", "CAA"):
# anything goes
pass

View File

@ -45,7 +45,7 @@ def get_ssl_certificates(env):
# Remember stuff.
private_keys = { }
certificates = [ ]
certificates = []
# Scan each of the files to find private keys and certificates.
# We must load all of the private keys first before processing
@ -73,6 +73,7 @@ def get_ssl_certificates(env):
domains = { }
for cert in certificates:
# What domains is this certificate good for?
# cert_domains = cert common name + all SANs, primary_domain = cert common name
cert_domains, primary_domain = get_certificate_domains(cert)
cert._primary_domain = primary_domain
@ -186,8 +187,16 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
from web_update import get_web_domains
from status_checks import query_dns, normalize_ip
# existing_certs = all valid certificates that are in /home/user-data/ssl or 1 level deep
# validity indicator -> private key exists for the cert public key?, not_expired?
# if multiple valid certs exist, then the one with the furthest expiry date and filename
# lexicographically smallest is returned
existing_certs = get_ssl_certificates(env)
# this function returns the list of domain names of all the email addresses and adds
# autoconfig, www, mta-sts, autodiscover subdomain to each of these domains
# if exclude_dns_elsewhere flag is set, then all the domains having A/AAAA record not on this machine
# are excluded
plausible_web_domains = get_web_domains(env, exclude_dns_elsewhere=False)
actual_web_domains = get_web_domains(env)
@ -212,7 +221,8 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
# how Let's Encrypt will connect.
bad_dns = []
for rtype, value in [("A", env["PUBLIC_IP"]), ("AAAA", env.get("PUBLIC_IPV6"))]:
if not value: continue # IPv6 is not configured
if not value:
continue # IPv6 is not configured
response = query_dns(domain, rtype)
if response != normalize_ip(value):
bad_dns.append("%s (%s)" % (response, rtype))
@ -226,6 +236,7 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
# DNS is all good.
# Check for a good existing cert.
# existing_cert = existing cert for domain
existing_cert = get_domain_ssl_files(domain, existing_certs, env, use_main_cert=False, allow_missing_cert=True)
if existing_cert:
existing_cert_check = check_certificate(domain, existing_cert['certificate'], existing_cert['private-key'],
@ -242,13 +253,24 @@ def get_certificates_to_provision(env, limit_domains=None, show_valid_certs=True
return (domains_to_provision, domains_cant_provision)
def provision_certificates(env, limit_domains):
def provision_certificates(env, limit_domains, domain_to_be_renewed=None, new_key=False):
# What domains should we provision certificates for? And what
# errors prevent provisioning for other domains.
ret = []
is_tlsa_update_required = False
if new_key:
from web_update import get_web_domains
domains = get_web_domains(env)
elif domain_to_be_renewed:
existing_certs = get_ssl_certificates(env)
existing_cert = get_domain_ssl_files(domain_to_be_renewed, existing_certs, env, use_main_cert=False, allow_missing_cert=True)
domains, primary_domain = get_certificate_domains(load_pem(load_cert_chain(existing_cert["certificate"])[0]))
else:
# domains = domains for which a certificate can be provisioned
# domains_cant_provision = domains for which a certificate can't be provisioned and the reason
domains, domains_cant_provision = get_certificates_to_provision(env, limit_domains=limit_domains)
# Build a list of what happened on each domain or domain-set.
ret = []
for domain, error in domains_cant_provision.items():
ret.append({
"domains": [domain],
@ -309,6 +331,8 @@ def provision_certificates(env, limit_domains):
# Create a CSR file for our master private key so that certbot
# uses our private key.
key_file = os.path.join(env['STORAGE_ROOT'], 'ssl', 'ssl_private_key.pem')
if new_key:
key_file = os.path.join(env['STORAGE_ROOT'], 'ssl', 'next_ssl_private_key.pem')
with tempfile.NamedTemporaryFile() as csr_file:
# We could use openssl, but certbot requires
# that the CN domain and SAN domains match
@ -363,6 +387,8 @@ def provision_certificates(env, limit_domains):
ret[-1]["log"].append(certbotret)
ret[-1]["result"] = "installed"
if new_key and env['PRIMARY_HOSTNAME'] in domains:
is_tlsa_update_required = True
except subprocess.CalledProcessError as e:
ret[-1]["log"].append(e.output.decode("utf8"))
ret[-1]["result"] = "error"
@ -371,7 +397,7 @@ def provision_certificates(env, limit_domains):
ret[-1]["result"] = "error"
# Run post-install steps.
ret.extend(post_install_func(env))
ret.extend(post_install_func(env, is_tlsa_update_required=is_tlsa_update_required))
# Return what happened with each certificate request.
return ret
@ -466,7 +492,7 @@ def install_cert_copy_file(fn, env):
shutil.move(fn, ssl_certificate)
def post_install_func(env):
def post_install_func(env, is_tlsa_update_required=False):
ret = []
# Get the certificate to use for PRIMARY_HOSTNAME.
@ -496,6 +522,25 @@ def post_install_func(env):
# The DANE TLSA record will remain valid so long as the private key
# hasn't changed. We don't ever change the private key automatically.
# If the user does it, they must manually update DNS.
if is_tlsa_update_required:
from dns_update import do_dns_update, set_custom_dns_record, build_tlsa_record
subprocess.check_output([
"mv", env["STORAGE_ROOT"] + "/ssl/next_ssl_private_key.pem",
env["STORAGE_ROOT"] + "/ssl/ssl_private_key.pem"
])
subprocess.check_output([
"openssl", "genrsa",
"-out", env["STORAGE_ROOT"] + "/ssl/next_ssl_private_key.pem",
"2048"])
qname1 = "_25._tcp." + env['PRIMARY_HOSTNAME']
qname2 = "_443._tcp." + env['PRIMARY_HOSTNAME']
rtype = "TLSA"
value = build_tlsa_record(env, from_cert=False)
action = "add"
if set_custom_dns_record(qname1, rtype, value, action, env):
set_custom_dns_record(qname2, rtype, value, action, env)
ret.append(do_dns_update(env))
# Update the web configuration so nginx picks up the new certificate file.
from web_update import do_web_update

View File

@ -454,9 +454,9 @@ def check_primary_hostname_dns(domain, env, output, dns_domains, dns_zonefiles):
# Check the TLSA record.
tlsa_qname = "_25._tcp." + domain
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None)
tlsa25 = query_dns(tlsa_qname, "TLSA", nxdomain=None).split('; ')
tlsa25_expected = build_tlsa_record(env)
if tlsa25 == tlsa25_expected:
if tlsa25_expected in tlsa25:
output.print_ok("""The DANE TLSA record for incoming mail is correct (%s).""" % tlsa_qname,)
elif tlsa25 is None:
if has_dnssec:

View File

@ -40,7 +40,10 @@
<h3 id="ssl_install_header">Install certificate</h3>
<p>If you don't want to use our automatic Let's Encrypt integration, you can give any other certificate provider a try. You can generate the needed CSR below.</p>
<p>If you don't want to use our automatic Let's Encrypt integration, you can give any other certificate provider a try. Click on install certificate button
if there is no certificate for your intended domain or
click on renew or replace certificate button and click replace if there is an existing certificate and you want to replace it with a new one from a different CA.
You can generate the needed CSR below.</p>
<p>Which domain are you getting a certificate for?</p>
@ -101,7 +104,8 @@ function show_tls(keep_provisioning_shown) {
$('#ssldomain').html('<option value="">(select)</option>');
$('#ssl_domains').show();
for (var i = 0; i < domains.length; i++) {
var row = $("<tr><th class='domain'><a href=''></a></th><td class='status'></td> <td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>");
var row = $("<tr><th class='domain'><a href=''></a></th><td class='status'></td> " +
"<td class='actions'><a href='#' onclick='return ssl_install(this);' class='btn btn-xs'>Install Certificate</a></td></tr>");
tb.append(row);
row.attr('data-domain', domains[i].domain);
row.find('.domain a').text(domains[i].domain);
@ -113,7 +117,10 @@ function show_tls(keep_provisioning_shown) {
row.addClass("text-" + domains[i].status);
row.find('.status').text(domains[i].text);
if (domains[i].status == "success") {
row.find('.actions a').addClass('btn-default').text('Replace Certificate');
row.find('.actions a').addClass('btn-default').text('Renew or replace Certificate');
row.find('.actions a').addClass('btn-default').on("click", function () {
ssl_renew_or_replace_modal(this);
});
} else {
row.find('.actions a').addClass('btn-primary').text('Install Certificate');
}
@ -131,6 +138,65 @@ function ssl_install(elem) {
return false;
}
function ssl_renew_or_replace_modal(elem) {
show_modal_confirm(
"Options",
"Do you want to replace the certificate with a new one or just renew this one?",
["Replace", "Renew"],
function () {
ssl_install(elem);
},
function () {
ssl_cert_renew(elem);
});
}
function ssl_cert_renew(elem) {
var domain = $(elem).parents('tr').attr('data-domain');
show_modal_confirm(
"Options",
"Do you want to renew with the existing key?",
["Yes", "No"],
function () {
ajax_with_indicator(true);
api(
"/ssl/renew/" + domain,
"POST",
{
existing_key: "yes"
},
function(data) {
$('#ajax_loading_indicator').stop(true).hide();
show_modal_error(data["title"], data["log"]);
show_tls(true);
},
function () {
$('#ajax_loading_indicator').stop(true).hide();
show_modal_error("Error", "Something is not right, sorry!");
show_tls(true);
});
},
function () {
ajax_with_indicator(true);
api(
"/ssl/renew/" + domain,
"POST",
{
existing_key: "no"
},
function(data) {
$('#ajax_loading_indicator').stop(true).hide();
show_modal_error(data["title"], data["log"]);
show_tls(true);
},
function () {
$('#ajax_loading_indicator').stop(true).hide();
show_modal_error("Error", "Something is not right, sorry!");
show_tls(true);
}
);
});
}
function show_csr() {
// Can't show a CSR until both inputs are entered.
if ($('#ssldomain').val() == "") return;

View File

@ -66,6 +66,13 @@ if [ ! -f $STORAGE_ROOT/ssl/ssl_private_key.pem ]; then
openssl genrsa -out $STORAGE_ROOT/ssl/ssl_private_key.pem 2048)
fi
# for Double TLSA scheme. More details here (https://mail.sys4.de/pipermail/dane-users/2018-February/000440.html)
if [ ! -f $STORAGE_ROOT/ssl/next_ssl_private_key.pem ]; then
# Set the umask so the key file is never world-readable.
(umask 077; hide_output \
openssl genrsa -out $STORAGE_ROOT/ssl/next_ssl_private_key.pem 2048)
fi
# Generate a self-signed SSL certificate because things like nginx, dovecot,
# etc. won't even start without some certificate in place, and we need nginx
# so we can offer the user a control panel to install a better certificate.