2018-01-15 17:43:32 +00:00
#!/usr/local/lib/mailinabox/env/bin/python
2015-11-29 13:59:22 +00:00
# Utilities for installing and selecting SSL certificates.
2018-05-13 00:02:25 +00:00
import os , os . path , re , shutil , subprocess , tempfile
2015-11-29 13:59:22 +00:00
2015-10-10 22:03:55 +00:00
from utils import shell , safe_domain_name , sort_domains
2023-12-22 15:23:24 +00:00
import functools
import operator
2016-01-01 17:10:32 +00:00
2015-10-10 22:03:55 +00:00
# SELECTING SSL CERTIFICATES FOR USE IN WEB
2015-11-29 13:59:22 +00:00
def get_ssl_certificates ( env ) :
# Scan all of the installed SSL certificates and map every domain
# that the certificates are good for to the best certificate for
# the domain.
2024-12-22 12:59:58 +00:00
from cryptography . hazmat . primitives . asymmetric import dsa , rsa , ec
2015-11-29 13:59:22 +00:00
from cryptography . x509 import Certificate
# The certificates are all stored here:
ssl_root = os . path . join ( env [ " STORAGE_ROOT " ] , ' ssl ' )
# List all of the files in the SSL directory and one level deep.
def get_file_list ( ) :
2015-10-10 22:03:55 +00:00
if not os . path . exists ( ssl_root ) :
return
2015-11-29 13:59:22 +00:00
for fn in os . listdir ( ssl_root ) :
2018-05-13 00:02:25 +00:00
if fn == ' ssl_certificate.pem ' :
# This is always a symbolic link
# to the certificate to use for
# PRIMARY_HOSTNAME. Don't let it
# be eligible for use because we
# could end up creating a symlink
# to itself --- we want to find
# the cert that it should be a
# symlink to.
continue
2015-11-29 13:59:22 +00:00
fn = os . path . join ( ssl_root , fn )
if os . path . isfile ( fn ) :
yield fn
elif os . path . isdir ( fn ) :
for fn1 in os . listdir ( fn ) :
fn1 = os . path . join ( fn , fn1 )
if os . path . isfile ( fn1 ) :
yield fn1
# Remember stuff.
private_keys = { }
certificates = [ ]
# Scan each of the files to find private keys and certificates.
# We must load all of the private keys first before processing
# certificates so that we can check that we have a private key
# available before using a certificate.
for fn in get_file_list ( ) :
try :
pem = load_pem ( load_cert_chain ( fn ) [ 0 ] )
except ValueError :
# Not a valid PEM format for a PEM type we care about.
continue
# Is it a certificate?
if isinstance ( pem , Certificate ) :
2022-06-19 10:48:55 +00:00
certificates . append ( { " filename " : fn , " cert " : pem } )
2024-12-22 12:59:58 +00:00
# It is a private key
elif ( isinstance ( pem , rsa . RSAPrivateKey )
or isinstance ( pem , dsa . DSAPrivateKey )
or isinstance ( pem , ec . EllipticCurvePrivateKey ) ) :
private_keys [ pem . public_key ( ) . public_numbers ( ) ] = { " filename " : fn , " key " : pem }
2015-11-29 13:59:22 +00:00
# Process the certificates.
domains = { }
for cert in certificates :
# What domains is this certificate good for?
2022-06-19 10:48:55 +00:00
cert_domains , primary_domain = get_certificate_domains ( cert [ " cert " ] )
cert [ " primary_domain " ] = primary_domain
2015-11-29 13:59:22 +00:00
# Is there a private key file for this certificate?
2022-06-19 10:48:55 +00:00
private_key = private_keys . get ( cert [ " cert " ] . public_key ( ) . public_numbers ( ) )
2015-11-29 13:59:22 +00:00
if not private_key :
continue
2022-06-19 10:48:55 +00:00
cert [ " private_key " ] = private_key
2015-11-29 13:59:22 +00:00
# Add this cert to the list of certs usable for the domains.
for domain in cert_domains :
2018-05-13 00:02:25 +00:00
# The primary hostname can only use a certificate mapped
# to the system private key.
2023-12-22 15:20:00 +00:00
if domain == env [ ' PRIMARY_HOSTNAME ' ] and cert [ " private_key " ] [ " filename " ] != os . path . join ( env [ ' STORAGE_ROOT ' ] , ' ssl ' , ' ssl_private_key.pem ' ) :
continue
2018-05-13 00:02:25 +00:00
2015-11-29 13:59:22 +00:00
domains . setdefault ( domain , [ ] ) . append ( cert )
# Sort the certificates to prefer good ones.
import datetime
now = datetime . datetime . utcnow ( )
ret = { }
for domain , cert_list in domains . items ( ) :
2018-05-13 00:02:25 +00:00
#for c in cert_list: print(domain, c.not_valid_before, c.not_valid_after, "("+str(now)+")", c.issuer, c.subject, c._filename)
2015-11-29 13:59:22 +00:00
cert_list . sort ( key = lambda cert : (
# must be valid NOW
2022-06-19 10:48:55 +00:00
cert [ " cert " ] . not_valid_before < = now < = cert [ " cert " ] . not_valid_after ,
2015-11-29 13:59:22 +00:00
# prefer one that is not self-signed
2022-06-19 10:48:55 +00:00
cert [ " cert " ] . issuer != cert [ " cert " ] . subject ,
2015-11-29 13:59:22 +00:00
2015-10-10 22:03:55 +00:00
###########################################################
# The above lines ensure that valid certificates are chosen
# over invalid certificates. The lines below choose between
# multiple valid certificates available for this domain.
###########################################################
2015-11-29 13:59:22 +00:00
# prefer one with the expiration furthest into the future so
# that we can easily rotate to new certs as we get them
2022-06-19 10:48:55 +00:00
cert [ " cert " ] . not_valid_after ,
2015-11-29 13:59:22 +00:00
2015-10-10 22:03:55 +00:00
###########################################################
# We always choose the certificate that is good for the
# longest period of time. This is important for how we
# provision certificates for Let's Encrypt. To ensure that
# we don't re-provision every night, we have to ensure that
# if we choose to provison a certificate that it will
# *actually* be used so the provisioning logic knows it
# doesn't still need to provision a certificate for the
# domain.
###########################################################
2015-11-29 13:59:22 +00:00
# in case a certificate is installed in multiple paths,
# prefer the... lexicographically last one?
2022-06-19 10:48:55 +00:00
cert [ " filename " ] ,
2015-11-29 13:59:22 +00:00
) , reverse = True )
cert = cert_list . pop ( 0 )
ret [ domain ] = {
2022-06-19 10:48:55 +00:00
" private-key " : cert [ " private_key " ] [ " filename " ] ,
" certificate " : cert [ " filename " ] ,
" primary-domain " : cert [ " primary_domain " ] ,
" certificate_object " : cert [ " cert " ] ,
2015-11-29 13:59:22 +00:00
}
return ret
2018-05-13 00:02:25 +00:00
def get_domain_ssl_files ( domain , ssl_certificates , env , allow_missing_cert = False , use_main_cert = True ) :
2018-07-19 15:40:49 +00:00
if use_main_cert or not allow_missing_cert :
2018-05-13 00:02:25 +00:00
# Get the system certificate info.
ssl_private_key = os . path . join ( os . path . join ( env [ " STORAGE_ROOT " ] , ' ssl ' , ' ssl_private_key.pem ' ) )
ssl_certificate = os . path . join ( os . path . join ( env [ " STORAGE_ROOT " ] , ' ssl ' , ' ssl_certificate.pem ' ) )
system_certificate = {
" private-key " : ssl_private_key ,
" certificate " : ssl_certificate ,
" primary-domain " : env [ ' PRIMARY_HOSTNAME ' ] ,
" certificate_object " : load_pem ( load_cert_chain ( ssl_certificate ) [ 0 ] ) ,
}
2023-12-22 15:20:00 +00:00
if use_main_cert and domain == env [ ' PRIMARY_HOSTNAME ' ] :
# The primary domain must use the server certificate because
# it is hard-coded in some service configuration files.
return system_certificate
2015-11-29 13:59:22 +00:00
2023-12-22 15:19:40 +00:00
wildcard_domain = re . sub ( r " ^[^ \ .]+ " , " * " , domain )
2015-11-29 13:59:22 +00:00
if domain in ssl_certificates :
2015-10-10 22:03:55 +00:00
return ssl_certificates [ domain ]
2015-11-29 13:59:22 +00:00
elif wildcard_domain in ssl_certificates :
2015-10-10 22:03:55 +00:00
return ssl_certificates [ wildcard_domain ]
2015-11-29 13:59:22 +00:00
elif not allow_missing_cert :
2015-10-10 22:03:55 +00:00
# No valid certificate is available for this domain! Return default files.
return system_certificate
2015-11-29 13:59:22 +00:00
else :
2015-10-10 22:03:55 +00:00
# No valid certificate is available for this domain.
2015-11-29 13:59:22 +00:00
return None
2015-10-10 22:03:55 +00:00
# PROVISIONING CERTIFICATES FROM LETSENCRYPT
2018-05-13 00:02:25 +00:00
def get_certificates_to_provision ( env , limit_domains = None , show_valid_certs = True ) :
# Get a set of domain names that we can provision certificates for
# using certbot. We start with domains that the box is serving web
# for and subtract:
# * domains not in limit_domains if limit_domains is not empty
# * domains with custom "A" records, i.e. they are hosted elsewhere
2020-05-22 11:05:24 +00:00
# * domains with actual "A" records that point elsewhere (misconfiguration)
2018-05-13 00:02:25 +00:00
# * domains that already have certificates that will be valid for a while
2015-10-10 22:03:55 +00:00
from web_update import get_web_domains
2018-05-13 00:02:25 +00:00
from status_checks import query_dns , normalize_ip
2015-10-10 22:03:55 +00:00
2018-05-13 00:02:25 +00:00
existing_certs = get_ssl_certificates ( env )
2016-01-02 22:53:47 +00:00
2018-05-13 00:02:25 +00:00
plausible_web_domains = get_web_domains ( env , exclude_dns_elsewhere = False )
actual_web_domains = get_web_domains ( env )
2016-01-01 17:10:32 +00:00
2018-05-13 00:02:25 +00:00
domains_to_provision = set ( )
domains_cant_provision = { }
2016-01-01 17:10:32 +00:00
2018-05-13 00:02:25 +00:00
for domain in plausible_web_domains :
# Skip domains that the user doesn't want to provision now.
if limit_domains and domain not in limit_domains :
continue
2015-10-10 22:03:55 +00:00
2018-05-13 00:02:25 +00:00
# Check that there isn't an explicit A/AAAA record.
if domain not in actual_web_domains :
domains_cant_provision [ domain ] = " The domain has a custom DNS A/AAAA record that points the domain elsewhere, so there is no point to installing a TLS certificate here and we could not automatically provision one anyway because provisioning requires access to the website (which isn ' t here). "
2015-10-10 22:03:55 +00:00
2018-05-13 00:02:25 +00:00
# Check that the DNS resolves to here.
else :
2015-10-10 22:03:55 +00:00
2018-05-13 00:02:25 +00:00
# Does the domain resolve to this machine in public DNS? If not,
# we can't do domain control validation. For IPv6 is configured,
# make sure both IPv4 and IPv6 are correct because we don't know
# 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
response = query_dns ( domain , rtype )
if response != normalize_ip ( value ) :
2023-12-22 15:41:21 +00:00
bad_dns . append ( f " { response } ( { rtype } ) " )
2020-10-16 20:22:36 +00:00
2018-05-13 00:02:25 +00:00
if bad_dns :
domains_cant_provision [ domain ] = " The domain name does not resolve to this machine: " \
+ ( " , " . join ( bad_dns ) ) \
+ " . "
2020-10-16 20:22:36 +00:00
2018-05-13 00:02:25 +00:00
else :
# DNS is all good.
# Check for a good existing cert.
2018-07-19 15:40:49 +00:00
existing_cert = get_domain_ssl_files ( domain , existing_certs , env , use_main_cert = False , allow_missing_cert = True )
2018-05-13 00:02:25 +00:00
if existing_cert :
existing_cert_check = check_certificate ( domain , existing_cert [ ' certificate ' ] , existing_cert [ ' private-key ' ] ,
warn_if_expiring_soon = 14 )
if existing_cert_check [ 0 ] == " OK " :
if show_valid_certs :
domains_cant_provision [ domain ] = " The domain has a valid certificate already. ( {} Certificate: {} , private key {} ) " . format (
existing_cert_check [ 1 ] ,
existing_cert [ ' certificate ' ] ,
existing_cert [ ' private-key ' ] )
continue
domains_to_provision . add ( domain )
return ( domains_to_provision , domains_cant_provision )
def provision_certificates ( env , limit_domains ) :
2016-01-01 17:10:32 +00:00
# What domains should we provision certificates for? And what
# errors prevent provisioning for other domains.
2018-05-13 00:02:25 +00:00
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 " ,
} )
2016-01-01 17:10:32 +00:00
2020-05-22 11:05:24 +00:00
# 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.
# Start with the zones so that if the zone doesn't need a certificate itself,
# its children will still be grouped together. Sort the provision domains to
# put parents ahead of children.
# Since Let's Encrypt requests are limited to 100 domains at a time,
# we'll create a list of lists of domains where the inner lists have
# at most 100 items. By sorting we also get the DNS zone domain as the first
# entry in each list (unless we overflow beyond 100) which ends up as the
# primary domain listed in each certificate.
from dns_update import get_dns_zones
certs = { }
2023-12-22 15:21:10 +00:00
for zone , _zonefile in get_dns_zones ( env ) :
2020-05-22 11:05:24 +00:00
certs [ zone ] = [ [ ] ]
for domain in sort_domains ( domains , env ) :
# Does the domain end with any domain we've seen so far.
2023-12-22 15:23:15 +00:00
for parent in certs :
2020-05-22 11:05:24 +00:00
if domain . endswith ( " . " + parent ) :
# Add this to the parent's list of domains.
# Start a new group if the list already has
# 100 items.
if len ( certs [ parent ] [ - 1 ] ) == 100 :
certs [ parent ] . append ( [ ] )
certs [ parent ] [ - 1 ] . append ( domain )
break
else :
# This domain is not a child of any domain we've seen yet, so
# start a new group. This shouldn't happen since every zone
# was already added.
certs [ domain ] = [ [ domain ] ]
# Flatten to a list of lists of domains (from a mapping). Remove empty
# lists (zones with no domains that need certs).
2023-12-22 15:23:24 +00:00
certs = functools . reduce ( operator . iadd , certs . values ( ) , [ ] )
2020-05-22 11:05:24 +00:00
certs = [ _ for _ in certs if len ( _ ) > 0 ]
2016-01-01 17:10:32 +00:00
2015-10-10 22:03:55 +00:00
# Prepare to provision.
# Where should we put our Let's Encrypt account info and state cache.
account_path = os . path . join ( env [ ' STORAGE_ROOT ' ] , ' ssl/lets_encrypt ' )
if not os . path . exists ( account_path ) :
os . mkdir ( account_path )
# Provision certificates.
for domain_list in certs :
2018-05-13 00:02:25 +00:00
ret . append ( {
2015-10-10 22:03:55 +00:00
" domains " : domain_list ,
" log " : [ ] ,
2018-05-13 00:02:25 +00:00
} )
2015-10-10 22:03:55 +00:00
try :
2018-05-13 00:02:25 +00:00
# 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 ' )
with tempfile . NamedTemporaryFile ( ) as csr_file :
# We could use openssl, but certbot requires
# that the CN domain and SAN domains match
# the domain list passed to certbot, and adding
# SAN domains openssl req is ridiculously complicated.
# subprocess.check_output([
# "openssl", "req", "-new",
# "-key", key_file,
# "-out", csr_file.name,
# "-subj", "/CN=" + domain_list[0],
# "-sha256" ])
from cryptography import x509
from cryptography . hazmat . backends import default_backend
from cryptography . hazmat . primitives . serialization import Encoding
from cryptography . hazmat . primitives import hashes
from cryptography . x509 . oid import NameOID
builder = x509 . CertificateSigningRequestBuilder ( )
builder = builder . subject_name ( x509 . Name ( [ x509 . NameAttribute ( NameOID . COMMON_NAME , domain_list [ 0 ] ) ] ) )
builder = builder . add_extension ( x509 . BasicConstraints ( ca = False , path_length = None ) , critical = True )
builder = builder . add_extension ( x509 . SubjectAlternativeName (
[ x509 . DNSName ( d ) for d in domain_list ]
) , critical = False )
request = builder . sign ( load_pem ( load_cert_chain ( key_file ) [ 0 ] ) , hashes . SHA256 ( ) , default_backend ( ) )
with open ( csr_file . name , " wb " ) as f :
f . write ( request . public_bytes ( Encoding . PEM ) )
# Provision, writing to a temporary file.
webroot = os . path . join ( account_path , ' webroot ' )
os . makedirs ( webroot , exist_ok = True )
with tempfile . TemporaryDirectory ( ) as d :
cert_file = os . path . join ( d , ' cert_and_chain.pem ' )
print ( " Provisioning TLS certificates for " + " , " . join ( domain_list ) + " . " )
certbotret = subprocess . check_output ( [
" certbot " ,
" certonly " ,
#"-v", # just enough to see ACME errors
" --non-interactive " , # will fail if user hasn't registered during Mail-in-a-Box setup
" -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
" --chain-path " , os . path . join ( d , ' chain ' ) , # we only use the full chain
" --fullchain-path " , cert_file ,
" --webroot " , " --webroot-path " , webroot ,
" --config-dir " , account_path ,
#"--staging",
] , stderr = subprocess . STDOUT ) . decode ( " utf8 " )
install_cert_copy_file ( cert_file , env )
ret [ - 1 ] [ " log " ] . append ( certbotret )
ret [ - 1 ] [ " result " ] = " installed "
except subprocess . CalledProcessError as e :
ret [ - 1 ] [ " log " ] . append ( e . output . decode ( " utf8 " ) )
ret [ - 1 ] [ " result " ] = " error "
except Exception as e :
ret [ - 1 ] [ " log " ] . append ( str ( e ) )
ret [ - 1 ] [ " result " ] = " error "
# Run post-install steps.
ret . extend ( post_install_func ( env ) )
2015-10-10 22:03:55 +00:00
# Return what happened with each certificate request.
2018-05-13 00:02:25 +00:00
return ret
2015-10-10 22:03:55 +00:00
def provision_certificates_cmdline ( ) :
import sys
2017-01-01 22:11:31 +00:00
from exclusiveprocess import Lock
2015-10-10 22:03:55 +00:00
2017-01-01 22:11:31 +00:00
from utils import load_environment
Lock ( die = True ) . forever ( )
2015-10-10 22:03:55 +00:00
env = load_environment ( )
2018-05-13 00:02:25 +00:00
quiet = False
domains = [ ]
2015-10-10 22:03:55 +00:00
2018-05-13 00:02:25 +00:00
for arg in sys . argv [ 1 : ] :
if arg == " -q " :
quiet = True
else :
domains . append ( arg )
2015-10-10 22:03:55 +00:00
2018-05-13 00:02:25 +00:00
# Go.
status = provision_certificates ( env , limit_domains = domains )
2015-10-10 22:03:55 +00:00
2018-05-13 00:02:25 +00:00
# Show what happened.
for request in status :
if isinstance ( request , str ) :
print ( request )
else :
if quiet and request [ ' result ' ] == ' skipped ' :
continue
print ( request [ ' result ' ] + " : " , " , " . join ( request [ ' domains ' ] ) + " : " )
for line in request [ " log " ] :
print ( line )
2015-10-10 22:03:55 +00:00
print ( )
2018-05-13 00:02:25 +00:00
2016-01-01 17:10:32 +00:00
2015-10-10 22:03:55 +00:00
# INSTALLING A NEW CERTIFICATE FROM THE CONTROL PANEL
2015-11-29 13:59:22 +00:00
2015-12-26 16:48:23 +00:00
def create_csr ( domain , ssl_key , country_code , env ) :
2015-11-29 13:59:22 +00:00
return shell ( " check_output " , [
2018-05-13 00:02:25 +00:00
" openssl " , " req " , " -new " ,
" -key " , ssl_key ,
" -sha256 " ,
2023-12-22 15:41:21 +00:00
" -subj " , f " /C= { country_code } /CN= { domain } " ] )
2015-11-29 13:59:22 +00:00
2015-10-10 22:03:55 +00:00
def install_cert ( domain , ssl_cert , ssl_chain , env , raw = False ) :
2015-11-29 13:59:22 +00:00
# Write the combined cert+chain to a temporary path and validate that it is OK.
# The certificate always goes above the chain.
import tempfile
fd , fn = tempfile . mkstemp ( ' .pem ' )
os . write ( fd , ( ssl_cert + ' \n ' + ssl_chain ) . encode ( " ascii " ) )
os . close ( fd )
# Do validation on the certificate before installing it.
ssl_private_key = os . path . join ( os . path . join ( env [ " STORAGE_ROOT " ] , ' ssl ' , ' ssl_private_key.pem ' ) )
cert_status , cert_status_details = check_certificate ( domain , fn , ssl_private_key )
if cert_status != " OK " :
if cert_status == " SELF-SIGNED " :
cert_status = " This is a self-signed certificate. I can ' t install that. "
os . unlink ( fn )
if cert_status_details is not None :
cert_status + = " " + cert_status_details
return cert_status
2024-07-21 11:01:25 +00:00
# Copy certificate into ssl directory.
2018-05-13 00:02:25 +00:00
install_cert_copy_file ( fn , env )
# Run post-install steps.
ret = post_install_func ( env )
if raw : return ret
return " \n " . join ( ret )
def install_cert_copy_file ( fn , env ) :
2015-11-29 13:59:22 +00:00
# Where to put it?
# Make a unique path for the certificate.
from cryptography . hazmat . primitives import hashes
from binascii import hexlify
cert = load_pem ( load_cert_chain ( fn ) [ 0 ] )
2023-12-22 15:15:14 +00:00
_all_domains , cn = get_certificate_domains ( cert )
2023-12-22 15:19:06 +00:00
path = " {} - {} - {} .pem " . format (
2015-11-29 14:04:37 +00:00
safe_domain_name ( cn ) , # common name, which should be filename safe because it is IDNA-encoded, but in case of a malformed cert make sure it's ok to use as a filename
2015-11-29 13:59:22 +00:00
cert . not_valid_after . date ( ) . isoformat ( ) . replace ( " - " , " " ) , # expiration date
hexlify ( cert . fingerprint ( hashes . SHA256 ( ) ) ) . decode ( " ascii " ) [ 0 : 8 ] , # fingerprint prefix
)
ssl_certificate = os . path . join ( os . path . join ( env [ " STORAGE_ROOT " ] , ' ssl ' , path ) )
# Install the certificate.
os . makedirs ( os . path . dirname ( ssl_certificate ) , exist_ok = True )
shutil . move ( fn , ssl_certificate )
2018-05-13 00:02:25 +00:00
def post_install_func ( env ) :
ret = [ ]
# Get the certificate to use for PRIMARY_HOSTNAME.
ssl_certificates = get_ssl_certificates ( env )
cert = get_domain_ssl_files ( env [ ' PRIMARY_HOSTNAME ' ] , ssl_certificates , env , use_main_cert = False )
if not cert :
# Ruh-row, we don't have any certificate usable
# for the primary hostname.
ret . append ( " there is no valid certificate for " + env [ ' PRIMARY_HOSTNAME ' ] )
# Symlink the best cert for PRIMARY_HOSTNAME to the system
2015-11-29 13:59:22 +00:00
# certificate path, which is hard-coded for various purposes, and then
2015-12-26 15:38:49 +00:00
# restart postfix and dovecot.
2018-05-13 00:02:25 +00:00
system_ssl_certificate = os . path . join ( os . path . join ( env [ " STORAGE_ROOT " ] , ' ssl ' , ' ssl_certificate.pem ' ) )
if cert and os . readlink ( system_ssl_certificate ) != cert [ ' certificate ' ] :
2015-11-29 13:59:22 +00:00
# Update symlink.
2018-05-13 00:02:25 +00:00
ret . append ( " updating primary certificate " )
ssl_certificate = cert [ ' certificate ' ]
2015-11-29 13:59:22 +00:00
os . unlink ( system_ssl_certificate )
os . symlink ( ssl_certificate , system_ssl_certificate )
2015-12-26 15:38:49 +00:00
# Restart postfix and dovecot so they pick up the new file.
2015-11-29 13:59:22 +00:00
shell ( ' check_call ' , [ " /usr/sbin/service " , " postfix " , " restart " ] )
shell ( ' check_call ' , [ " /usr/sbin/service " , " dovecot " , " restart " ] )
ret . append ( " mail services restarted " )
2015-12-26 15:38:49 +00:00
# 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.
2015-11-29 13:59:22 +00:00
# Update the web configuration so nginx picks up the new certificate file.
from web_update import do_web_update
ret . append ( do_web_update ( env ) )
2018-05-13 00:02:25 +00:00
return ret
2015-11-29 13:59:22 +00:00
2015-10-10 22:03:55 +00:00
# VALIDATION OF CERTIFICATES
2015-11-29 13:59:22 +00:00
2018-05-13 00:02:25 +00:00
def check_certificate ( domain , ssl_certificate , ssl_private_key , warn_if_expiring_soon = 10 , rounded_time = False , just_check_domain = False ) :
2015-11-29 13:59:22 +00:00
# Check that the ssl_certificate & ssl_private_key files are good
# for the provided domain.
2024-12-22 12:59:58 +00:00
from cryptography . hazmat . primitives . asymmetric import rsa , dsa , ec
2015-11-29 13:59:22 +00:00
from cryptography . x509 import Certificate
# The ssl_certificate file may contain a chain of certificates. We'll
# need to split that up before we can pass anything to openssl or
# parse them in Python. Parse it with the cryptography library.
try :
ssl_cert_chain = load_cert_chain ( ssl_certificate )
cert = load_pem ( ssl_cert_chain [ 0 ] )
if not isinstance ( cert , Certificate ) : raise ValueError ( " This is not a certificate file. " )
except ValueError as e :
return ( " There is a problem with the certificate file: %s " % str ( e ) , None )
# First check that the domain name is one of the names allowed by
# the certificate.
if domain is not None :
2023-12-22 15:15:14 +00:00
certificate_names , _cert_primary_name = get_certificate_domains ( cert )
2015-11-29 13:59:22 +00:00
# Check that the domain appears among the acceptable names, or a wildcard
# form of the domain name (which is a stricter check than the specs but
# should work in normal cases).
2023-12-22 15:19:40 +00:00
wildcard_domain = re . sub ( r " ^[^ \ .]+ " , " * " , domain )
2015-11-29 13:59:22 +00:00
if domain not in certificate_names and wildcard_domain not in certificate_names :
return ( " The certificate is for the wrong domain name. It is for %s . "
% " , " . join ( sorted ( certificate_names ) ) , None )
# Second, check that the certificate matches the private key.
if ssl_private_key is not None :
try :
2023-01-15 13:28:43 +00:00
with open ( ssl_private_key , ' rb ' ) as f :
priv_key = load_pem ( f . read ( ) )
2015-11-29 13:59:22 +00:00
except ValueError as e :
2023-12-22 15:42:13 +00:00
return ( f " The private key file { ssl_private_key } is not a private key file: { e !s} " , None )
2015-11-29 13:59:22 +00:00
2024-12-22 12:59:58 +00:00
if ( not isinstance ( priv_key , rsa . RSAPrivateKey )
and not isinstance ( priv_key , dsa . DSAPrivateKey )
and not isinstance ( priv_key , ec . EllipticCurvePrivateKey ) ) :
2015-11-29 13:59:22 +00:00
return ( " The private key file %s is not a private key file. " % ssl_private_key , None )
if priv_key . public_key ( ) . public_numbers ( ) != cert . public_key ( ) . public_numbers ( ) :
return ( " The certificate does not correspond to the private key at %s . " % ssl_private_key , None )
# We could also use the openssl command line tool to get the modulus
# listed in each file. The output of each command below looks like "Modulus=XXXXX".
# $ openssl rsa -inform PEM -noout -modulus -in ssl_private_key
# $ openssl x509 -in ssl_certificate -noout -modulus
# Third, check if the certificate is self-signed. Return a special flag string.
if cert . issuer == cert . subject :
return ( " SELF-SIGNED " , None )
# When selecting which certificate to use for non-primary domains, we check if the primary
# certificate or a www-parent-domain certificate is good for the domain. There's no need
# to run extra checks beyond this point.
if just_check_domain :
return ( " OK " , None )
# Check that the certificate hasn't expired. The datetimes returned by the
# certificate are 'naive' and in UTC. We need to get the current time in UTC.
import datetime
now = datetime . datetime . utcnow ( )
if not ( cert . not_valid_before < = now < = cert . not_valid_after ) :
2023-12-22 15:41:21 +00:00
return ( f " The certificate has expired or is not yet valid. It is valid from { cert . not_valid_before } to { cert . not_valid_after } . " , None )
2015-11-29 13:59:22 +00:00
# Next validate that the certificate is valid. This checks whether the certificate
# is self-signed, that the chain of trust makes sense, that it is signed by a CA
# that Ubuntu has installed on this machine's list of CAs, and I think that it hasn't
# expired.
# The certificate chain has to be passed separately and is given via STDIN.
# This command returns a non-zero exit status in most cases, so trap errors.
retcode , verifyoutput = shell ( ' check_output ' , [
" openssl " ,
" verify " , " -verbose " ,
" -purpose " , " sslserver " , " -policy_check " , ]
+ ( [ ] if len ( ssl_cert_chain ) == 1 else [ " -untrusted " , " /proc/self/fd/0 " ] )
+ [ ssl_certificate ] ,
input = b " \n \n " . join ( ssl_cert_chain [ 1 : ] ) ,
trap = True )
if " self signed " in verifyoutput :
# Certificate is self-signed. Probably we detected this above.
return ( " SELF-SIGNED " , None )
elif retcode != 0 :
if " unable to get local issuer certificate " in verifyoutput :
return ( " The certificate is missing an intermediate chain or the intermediate chain is incorrect or incomplete. ( %s ) " % verifyoutput , None )
# There is some unknown problem. Return the `openssl verify` raw output.
2016-01-02 23:01:20 +00:00
return ( " There is a problem with the certificate. " , verifyoutput . strip ( ) )
2015-11-29 13:59:22 +00:00
else :
# `openssl verify` returned a zero exit status so the cert is currently
# good.
# But is it expiring soon?
cert_expiration_date = cert . not_valid_after
ndays = ( cert_expiration_date - now ) . days
2015-10-10 22:03:55 +00:00
if not rounded_time or ndays < = 10 :
# Yikes better renew soon!
2020-10-16 20:22:36 +00:00
expiry_info = " The certificate expires in %d days on %s . " % ( ndays , cert_expiration_date . date ( ) . isoformat ( ) )
2015-11-29 13:59:22 +00:00
else :
2015-10-10 22:03:55 +00:00
# We'll renew it with Lets Encrypt.
2020-10-16 20:22:36 +00:00
expiry_info = " The certificate expires on %s . " % cert_expiration_date . date ( ) . isoformat ( )
2015-11-29 13:59:22 +00:00
2018-05-13 00:02:25 +00:00
if warn_if_expiring_soon and ndays < = warn_if_expiring_soon :
2015-10-10 22:03:55 +00:00
# Warn on day 10 to give 4 days for us to automatically renew the
# certificate, which occurs on day 14.
2015-11-29 13:59:22 +00:00
return ( " The certificate is expiring soon: " + expiry_info , None )
# Return the special OK code.
return ( " OK " , expiry_info )
def load_cert_chain ( pemfile ) :
# A certificate .pem file may contain a chain of certificates.
# Load the file and split them apart.
re_pem = rb " (-+BEGIN (?:.+)-+[ \ r \ n]+(?:[A-Za-z0-9+/=] { 1,64}[ \ r \ n]+)+-+END (?:.+)-+[ \ r \ n]+) "
with open ( pemfile , " rb " ) as f :
pem = f . read ( ) + b " \n " # ensure trailing newline
pemblocks = re . findall ( re_pem , pem )
if len ( pemblocks ) == 0 :
2023-12-22 15:10:48 +00:00
msg = " File does not contain valid PEM data. "
raise ValueError ( msg )
2015-11-29 13:59:22 +00:00
return pemblocks
def load_pem ( pem ) :
# Parse a "---BEGIN .... END---" PEM string and return a Python object for it
# using classes from the cryptography package.
from cryptography . x509 import load_pem_x509_certificate
from cryptography . hazmat . primitives import serialization
from cryptography . hazmat . backends import default_backend
pem_type = re . match ( b " -+BEGIN (.*?)-+[ \r \n ] " , pem )
if pem_type is None :
2023-12-22 15:10:48 +00:00
msg = " File is not a valid PEM-formatted file. "
raise ValueError ( msg )
2015-11-29 13:59:22 +00:00
pem_type = pem_type . group ( 1 )
2024-12-22 12:59:58 +00:00
if pem_type . endswith ( b " PRIVATE KEY " ) :
2015-11-29 13:59:22 +00:00
return serialization . load_pem_private_key ( pem , password = None , backend = default_backend ( ) )
if pem_type == b " CERTIFICATE " :
return load_pem_x509_certificate ( pem , default_backend ( ) )
raise ValueError ( " Unsupported PEM object type: " + pem_type . decode ( " ascii " , " replace " ) )
def get_certificate_domains ( cert ) :
from cryptography . x509 import DNSName , ExtensionNotFound , OID_COMMON_NAME , OID_SUBJECT_ALTERNATIVE_NAME
import idna
names = set ( )
cn = None
# The domain may be found in the Subject Common Name (CN). This comes back as an IDNA (ASCII)
# string, which is the format we store domains in - so good.
try :
cn = cert . subject . get_attributes_for_oid ( OID_COMMON_NAME ) [ 0 ] . value
names . add ( cn )
except IndexError :
# No common name? Certificate is probably generated incorrectly.
# But we'll let it error-out when it doesn't find the domain.
pass
# ... or be one of the Subject Alternative Names. The cryptography library handily IDNA-decodes
# the names for us. We must encode back to ASCII, but wildcard certificates can't pass through
# IDNA encoding/decoding so we must special-case. See https://github.com/pyca/cryptography/pull/2071.
def idna_decode_dns_name ( dns_name ) :
if dns_name . startswith ( " *. " ) :
return " *. " + idna . encode ( dns_name [ 2 : ] ) . decode ( ' ascii ' )
else :
return idna . encode ( dns_name ) . decode ( ' ascii ' )
try :
sans = cert . extensions . get_extension_for_oid ( OID_SUBJECT_ALTERNATIVE_NAME ) . value . get_values_for_type ( DNSName )
for san in sans :
names . add ( idna_decode_dns_name ( san ) )
except ExtensionNotFound :
pass
return names , cn
2015-10-10 22:03:55 +00:00
if __name__ == " __main__ " :
# Provision certificates.
provision_certificates_cmdline ( )