From e6657d6ebe4587ab060ce1c730f4ec989de96688 Mon Sep 17 00:00:00 2001 From: Ashiq5 Date: Wed, 4 Nov 2020 20:25:25 +0600 Subject: [PATCH] Added key rollover code. --- management/daemon.py | 74 +++++++++++++++++++++++++++++++- management/dns_update.py | 21 +++++++--- management/ssl_certificates.py | 77 +++++++++++++++++++++++++++------- management/status_checks.py | 4 +- management/templates/ssl.html | 72 +++++++++++++++++++++++++++++-- setup/ssl.sh | 9 +++- 6 files changed, 228 insertions(+), 29 deletions(-) diff --git a/management/daemon.py b/management/daemon.py index 956f6cf5..1698f9e8 100755 --- a/management/daemon.py +++ b/management/daemon.py @@ -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/', 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: diff --git a/management/dns_update.py b/management/dns_update.py index 748f87f1..7b3b766a 100755 --- a/management/dns_update.py +++ b/management/dns_update.py @@ -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 - 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(...) + 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) + 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 diff --git a/management/ssl_certificates.py b/management/ssl_certificates.py index cb4ee3d5..ec74b485 100755 --- a/management/ssl_certificates.py +++ b/management/ssl_certificates.py @@ -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,19 +253,30 @@ 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. - 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], - "log": [error], - "result": "skipped", - }) + 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. + for domain, error in domains_cant_provision.items(): + ret.append({ + "domains": [domain], + "log": [error], + "result": "skipped", + }) # Break into groups by DNS zone: Group every domain with its parent domain, if # its parent domain is in the list of domains to request a certificate for. @@ -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 @@ -345,9 +369,9 @@ def provision_certificates(env, limit_domains): "certbot", "certonly", #"-v", # just enough to see ACME errors - "--non-interactive", # will fail if user hasn't registered during Mail-in-a-Box setup + "--non-interactive", # will fail if user hasn't registered during Mail-in-a-Box setup - "-d", ",".join(domain_list), # first will be main domain + "-d", ",".join(domain_list), # first will be main domain "--csr", csr_file.name, # use our private key; unfortunately this doesn't work with auto-renew so we need to save cert manually "--cert-path", os.path.join(d, 'cert'), # we only use the full chain @@ -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 diff --git a/management/status_checks.py b/management/status_checks.py index 36da034a..7582a2e9 100755 --- a/management/status_checks.py +++ b/management/status_checks.py @@ -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: diff --git a/management/templates/ssl.html b/management/templates/ssl.html index a6b913ee..5a18e142 100644 --- a/management/templates/ssl.html +++ b/management/templates/ssl.html @@ -40,7 +40,10 @@

Install certificate

-

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.

+

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.

Which domain are you getting a certificate for?

@@ -101,7 +104,8 @@ function show_tls(keep_provisioning_shown) { $('#ssldomain').html(''); $('#ssl_domains').show(); for (var i = 0; i < domains.length; i++) { - var row = $(" Install Certificate"); + var row = $(" " + + "Install Certificate"); 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; diff --git a/setup/ssl.sh b/setup/ssl.sh index 61b0b9e5..2a249492 100755 --- a/setup/ssl.sh +++ b/setup/ssl.sh @@ -19,7 +19,7 @@ # # The Diffie-Hellman cipher bits are used for SMTP and HTTPS, when a # Diffie-Hellman cipher is selected during TLS negotiation. Diffie-Hellman -# provides Perfect Forward Secrecy. +# provides Perfect Forward Secrecy. source setup/functions.sh # load our functions source /etc/mailinabox.conf # load global vars @@ -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.