Installation
Cette note récapitule comment installer un certificat Let’s Encrypt de type DV ECDSA et automatiser le renouvellement avec la mode semi-manuel certonly de certbot. Si le type de certificat vous importe peu et que vous utilisez Apache, je recommande d’arrêter la lecture ici et de plutôt suivre la documentation du plugin automatique qui simplifiera beaucoup le travail.
Toujours là ? Pour commencer il faudra installer certbot. Le processus exact dépendra de la distribution, voici celle pour la dernière Debian Stable (Jessie, 8).
apt install certbot -t jessie-backports
Utilisation en mode manuel
Une fois certbot installé, commencer par préparer l’espace de travail avec quelques commandes pour créer les répertoires qu’on va utiliser. Par soucis de consistance avec le reste des outils Let’s Encrypt, l’espace de travail sera situé dans les même répertoires que la config de certbot.
mkdir -p /etc/letsencrypt/live/www.fangirl.eu/
cd /etc/letsencrypt/live/www.fangirl.eu/
Vient ensuite la génération de la clé privée et du CSR qui va bien.
openssl ecparam -genkey -name secp384r1 -out www.fangirl.eu-secp384r1.key
openssl req -new -sha256 -key www.fangirl.eu-secp384r1.key -subj "/CN=www.fangirl.eu" -reqexts SAN -config <(cat /etc/ssl/openssl.cnf <(printf "[SAN]\nsubjectAltName=DNS:www.fangirl.eu")) -outform pem -out www.fangirl.eu.csr
Une fois ces prérequis obtenus, plus qu’à faire la demande formelle à Let’s Encrypt.
Cette commande va effectuer une validation du contrôle effectif du domaine en plaçant une réponse à un challenge dans le DocumentRoot à /.well-known/acme-challenge/. Si votre application traite les URL d’une façon particulière (réécriture, mappage type WSGI), il faudra modifier la configuration de votre serveur pour que ces fichiers soient bien lus.
À noter que si vous voulez vous faire la main, vous pouvez rajouter l’argument --staging pour obtenir un certificat de l’infrastructure de test. Une option --dry-run est également disponible pour limiter les écritures, mais ne sera pas complètement sans effet secondaire, le challenge sera tout de même envoyé dans le DocumentRoot.
certbot certonly --webroot -w /var/www/xyz/www.fangirl.eu/ -d www.fangirl.eu --email user@example.fr --csr /etc/letsencrypt/www.fangirl.eu/www.fangirl.eu.csr
La première fois que vous ferez tourner certbot, il vous demandera d’accepter ses conditions d’utilisation. Vous pouvez automatiser la réponse avec l’option --agree-tos. Les fichiers seront par défaut écrit dans le répertoire courant avec 0000_cert.pem pour le certificat, 0000_chain.pem pour une chaîne sans votre certificat et 0001_chain.pem pour une chaîne complète. Les fichiers exacts nécessaires dépendront de votre serveur, Apache peut se contenter de la chaîne tronquée et du certificat.
Le numéro de série au début du fichier a pour but de permettre un renouvellement plus simple du certificat, qui incrémentera le numéro à chaque nouveau fichier. Attention toutefois à l’incrémentation de la chaîne qui rajoute deux fichiers à chaque demande.
Afin de ne pas perdre la boule, il est conseillé de créer des liens symboliques vers les derniers fichiers obtenus.
ln -s 0000_cert.pem latest_cert.crt
ln -s 0000_chain.pem latest_chain.pem
ln -s 0001_chain.pem latest_fullchain.pem
Rendu à ce point, nous avons un résultat utilisable et pouvons passer à la configuration du serveur.
Configuration Apache
Prenons Apache à titre d’exemple. Il est recommandé de diviser la configuration en deux parties, une au niveau httpd.conf qui donnera les paramètres génériques TLS, une autre par vhost pour les directives spécifiques.
Commençons par la configuration générique, qui sera sous Debian placée dans /etc/apache2/conf-available.
<IfModule mod_ssl.c>
SSLProtocol all -SSLv3
SSLCipherSuite ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA
SSLHonorCipherOrder on
SSLCompression off
SSLUseStapling on
SSLStaplingResponderTimeout 5
SSLStaplingReturnResponderErrors off
SSLStaplingCache shmcb:/run/ocsp(128000)
</IfModule>
Vous noterez que la liste SSLCipherSuite est très réduite, j’ai en effet limité aux suites récentes et adaptées aux certificats ECDSA. La compatibilité reste assez élevée, mais bloquera tout de même tous les Internet Explorer basé sur Windows XP, Android 2, Java 6 et OpenSSL 0.9.8. Sachant que ces vieux logiciels ne supportent pas les courbes elliptiques, leur absence est de toute façon inévitable.
Si à l’inverse, le site aura une audience restreinte utilisant uniquement des navigateurs modernes, il est possible de resserrer encore un peu la vis avec les paramètres suivants.
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
SSLCipherSuite ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384
Maintenant, seul TLS 1.2 sera autorisé, avec les dernières suites en rigueur. La compatibilité demandera au minimum IE 11, n’importe quelle version d’Edge, Safari 9 (iOS 9, OS X 10.11), Java 8, ou Android 5. Firefox et Chrome ne devraient pas poser de soucis dans tous les cas, étant indépendant des suites cryptographiques de l’OS hôte. Il est toutefois possible que vous ayez besoin de les mettre à jour si vous ne l’aviez pas fait depuis plusieurs années (Firefox l’a ajouté avec la 27.0 début 2014, Chrome avec la M29 mi-2013).
Enfin et pour finir, il faudra modifier la vhost pour ajouter les instructions propres à TLS. La recommandation dans un premier temps est de dupliquer la vhost actuelle pour avoir un site disponible à la fois en HTTP et HTTPS.
<VirtualHost *:443>
[...]
SSLEngine On
SSLCertificateChainFile /etc/letsencrypt/live/www.fangirl.eu/latest_chain.pem
SSLCertificateFile /etc/letsencrypt/live/www.fangirl.eu/latest_cert.crt
SSLCertificateKeyFile /etc/letsencrypt/live/www.fangirl.eu/www.fangirl.eu-secp384r1.key
[...]
</VirtualHost>
N’oubliez pas d’activer les confs et/ou vhost rajoutées, par exemple sous Debian avec a2ensite et a2enconf.
Une fois que tout marche bien en HTTPS, vous pourrez alors remplacer la vhost HTTP par une unique redirection redirigeant sur la version chiffrée.
<VirtualHost *:80>
ServerName www.fangirl.eu
RedirectPermanent / https://www.fangirl.eu/
</VirtualHost>
Et tant qu’à faire, rajouter HSTS au passage, qui imposera HTTPS pour de bon aux visiteurs.
Header always set Strict-Transport-Security "max-age=15768000"
À placer dans la vhost HTTPS, dépend de mod_headers que vous devrez sûrement activer si ce n’est déjà fait.
Automatisation
Vous avez donc un joli site en HTTPS, tout va bien dans le meilleur des mondes… Sauf que les certificats de Let’s Encrypt ne sont valides que pour 90 jours. Comme un oubli est si vite arrivé, il est fortement conseillé d’automatiser le renouvellement.
Malheureusement, Let’s Encrypt ne fournit pas d’outil tout prêt pour automatiser le renouvellement de certificats personnalisés comme ceux abordés dans cet article. À titre personnel, j’utilise les deux scripts “maison” suivants, mais ils s’appuient sur mes conventions d’organisation, en particulier des répertoires, pour minimiser la configuration. Lisez les donc bien et adaptez-les à votre usage avant de les adopter !
Initialisation
Ce script est codé pour Python 3, testé avec la version 3.4. Il utilise le module yaml pour sa configuration.
#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
import os, sys, pwd, grp, subprocess, argparse, tempfile
import yaml
CONF_FILE = '/etc/letsencrypt/setup.yaml'
def setup_env(certs_root, cn, owner, verbose = False):
path = os.path.join(certs_root, cn)
pw_owner = pwd.getpwnam(owner)
grp_sslcert = grp.getgrnam('ssl-cert')
os.makedirs(path)
os.chown(path, pw_owner.pw_uid, grp_sslcert.gr_gid)
def create_private_key(certs_root, cn, keytype = 'ecdsa', verbose = False):
if keytype == 'ecdsa':
key_path = os.path.join(certs_root, cn, '{}-secp384r1.key'.format(cn))
command = ['openssl', 'ecparam', '-genkey', '-name', 'secp384r1', '-out', key_path]
else:
print('Type de clé inconnu:', keytype, file=sys.stderr)
sys.exit(1)
if verbose:
subprocess.call(['echo'] + command)
ret_code = subprocess.call(command)
return key_path
def create_certificate_request(certs_root, cn, fqdns, key_path, verbose = False):
(fh, temp_conf) = tempfile.mkstemp()
csr_path = os.path.join(certs_root, cn, '{}.csr'.format(cn))
san = ','.join(['DNS:' + an for an in fqdns])
with open('/etc/ssl/openssl.cnf') as openssl_fh:
with open(temp_conf, 'w') as tp_fh:
tp_fh.write(openssl_fh.read())
print("[SAN]\nsubjectAltName={}".format(san), file=tp_fh)
command = ['openssl', 'req', '-new', '-sha256', '-key', key_path, '-subj', '/CN={}'.format(cn),
'-reqexts', 'SAN', '-config', temp_conf, '-outform', 'PEM', '-out', csr_path
]
if verbose:
subprocess.call(['echo'] + command)
ret_code = subprocess.call(command)
os.unlink(temp_conf)
return csr_path
def apply_for_certificate(certs_root, www_root, owner, cn, fqdns, csr_path, admin_email,
staging = False, verbose = False):
cert_symlink = 'latest_cert.crt'
chain_symlink = 'latest_chain.pem'
fullchain_symlink = 'latest_fullchain.pem'
new_cert = '0000_cert.crt'
new_fullchain = '0000_fullchain.pem'
new_chain = '0000_chain.pem'
webroot = os.path.join(www_root, owner, cn)
os.chdir(os.path.join(certs_root, cn))
command = ['certbot', 'certonly', '-n', '-q',
'--webroot', '-w', webroot,
]
for fqdn in fqdns:
command.extend(['-d', fqdn])
command.extend(['--email', admin_email,
'--csr', csr_path,
'--cert-path', os.path.join(certs_root, cn, new_cert),
'--fullchain-path', os.path.join(certs_root, cn, new_fullchain),
'--chain-path', os.path.join(certs_root, cn, new_chain),
])
if staging:
command.extend(['--staging', '--break-my-certs'])
if verbose:
subprocess.call(['echo'] + command)
ret_code = subprocess.call(command)
if ret_code == 0:
if os.path.exists(new_cert):
os.symlink(new_cert, cert_symlink)
if os.path.exists(new_chain):
os.symlink(new_chain, chain_symlink)
if os.path.exists(new_fullchain):
os.symlink(new_fullchain, fullchain_symlink)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('cn', help='main FQDN, used as CommonName')
parser.add_argument('owner', help='system user affiliated with certificate')
parser.add_argument('-v', '--verbose', action='store_true', help='talk more')
parser.add_argument('-s', '--staging', action='store_true',
help='issue staging certificates (useful for testing purposes)')
parser.add_argument('-k', '--keytype', default='ecdsa',
help='type of private key (either rsa or ecdsa, default: ecdsa)')
parser.add_argument('-a', '--altnames', default=None,
help='aliases for the certificate, used as SubjectAltName')
parser.add_argument('-c', '--config', default=CONF_FILE,
help='path to a config file (default: {})'.format(CONF_FILE))
args = parser.parse_args()
config = yaml.load(open(args.config))
if args.altnames:
fqdns = set([args.cn] + args.altnames.split(','))
else:
fqdns = [args.cn]
setup_env(config['certs_root'], args.cn, args.owner, args.verbose)
key_path = create_private_key(config['certs_root'], args.cn, args.keytype, args.verbose)
csr_path = create_certificate_request(config['certs_root'], args.cn, fqdns, key_path, args.verbose)
apply_for_certificate(config['certs_root'], config['www_root'], args.owner, args.cn, fqdns, csr_path,
config['admin_email'], staging=args.staging, verbose=args.verbose)
L’utilisation se fait comme suit :
./setup_letsencrypt.py www.fangirl.eu local_user -h
./setup_letsencrypt.py www.fangirl.eu local_user -v
./setup_letsencrypt.py www.fangirl.eu local_user -s -v # certificats staging
Renouvellement
Ce script est pensé pour être placé en cron, il est assez évolué pour ne renouveler les certificats qu’à l’approche (configurable) de la date d’expiration. Le seul argument notable est pour utiliser l’infrastructure staging. Il est également codé en Python 3, testé pour la version 3.4. Outre le module yaml pour sa configuration, il utilise pyasn1 et pyasn1_modules pour inspecter les certificats.
#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
import os, sys, pwd, subprocess, re, argparse
from datetime import datetime
from pyasn1_modules import pem, rfc2459
from pyasn1.codec.der import decoder
from pyasn1.type.univ import OctetString
import yaml
CONF_FILE = '/etc/letsencrypt/renew.yaml'
RE_CERTIFICATE_FILENAME = re.compile(r'^(\d+)_cert.crt$')
def parse_certificate(certificate_path):
fqdns = set()
substrate = pem.readPemFromFile(open(certificate_path))
cert = decoder.decode(substrate, asn1Spec=rfc2459.Certificate())[0]
core = cert['tbsCertificate']
# Extract CommonName
for rdnss in core['subject']:
for rdns in rdnss:
for name in rdns:
if name.getComponentByName('type') == rfc2459.id_at_commonName:
value = decoder.decode(name.getComponentByName('value'), asn1Spec=rfc2459.DirectoryString())[0]
fqdns.add(str(value.getComponent()))
# extract notAfter datetime
notAfter = str(core['validity'].getComponentByName('notAfter').getComponent()).strip('Z')
(year, month, day, hour, minute, seconds) = [int(notAfter[i:i+2]) for i in range(0, len(notAfter), 2)]
expiration_date = datetime(2000 + year, month, day, hour, minute, seconds)
# Extract SubjectAltName
for extension in core['extensions']:
if extension['extnID'] == rfc2459.id_ce_subjectAltName:
octet_string = decoder.decode(extension.getComponentByName('extnValue'), asn1Spec=OctetString())[0]
(san_list, r) = decoder.decode(octet_string, rfc2459.SubjectAltName())
for san_struct in san_list:
if san_struct.getName() == 'dNSName':
fqdns.add(str(san_struct.getComponent()))
return (fqdns, expiration_date)
def renew_certificate(cn, webroot, fqdns, working_dir, admin_email, staging = False, verbose = False):
cert_symlink = 'latest_cert.crt'
chain_symlink = 'latest_chain.pem'
fullchain_symlink = 'latest_fullchain.pem'
os.chdir(working_dir)
latest = os.readlink(cert_symlink)
serial = int(RE_CERTIFICATE_FILENAME.match(latest).group(1))
new_cert = '{:04d}_cert.crt'.format(serial + 1)
new_fullchain = '{:04d}_fullchain.pem'.format(serial + 1)
new_chain = '{:04d}_chain.pem'.format(serial + 1)
command = ['certbot', 'certonly', '-n', '-q',
'--webroot', '-w', webroot,
]
for fqdn in fqdns:
command.extend(['-d', fqdn])
command.extend(['--email', admin_email,
'--csr', os.path.join(working_dir, cn + '.csr'),
'--cert-path', os.path.join(working_dir, new_cert),
'--fullchain-path', os.path.join(working_dir, new_fullchain),
'--chain-path', os.path.join(working_dir, new_chain),
])
if staging:
command.extend(['--staging', '--break-my-certs'])
if verbose:
subprocess.call(['echo'] + command)
ret_code = subprocess.call(command)
if verbose:
print(ret_code)
if ret_code == 0:
if os.path.exists(new_cert):
if os.path.exists(cert_symlink):
os.remove(cert_symlink)
os.symlink(new_cert, cert_symlink)
if os.path.exists(new_chain):
if os.path.exists(chain_symlink):
os.remove(chain_symlink)
os.symlink(new_chain, chain_symlink)
if os.path.exists(new_fullchain):
if os.path.exists(fullchain_symlink):
os.remove(fullchain_symlink)
os.symlink(new_fullchain, fullchain_symlink)
def restart_daemons(daemons, verbose = False):
for daemon in daemons:
command = ['systemctl', daemon['action'], daemon['name']]
if verbose:
subprocess.call(['echo'] + command)
ret_code = subprocess.call(command)
if verbose:
print(ret_code)
def handle_certificates(cert_root, www_root, threshold, daemons, admin_email, staging = False, verbose = False):
will_restart_daemons = False
for site in os.listdir(cert_root):
if verbose:
print('Evaluating', site)
site_path = os.path.join(cert_root, site)
owner = pwd.getpwuid(os.stat(site_path).st_uid).pw_name
webroot = os.path.join(www_root, owner, site)
cert_path = os.path.join(site_path, 'latest_cert.crt')
if os.path.exists(cert_path):
(fqdns, expiration_date) = parse_certificate(cert_path)
if verbose:
print(fqdns)
now = datetime.now()
delta = expiration_date - now
if now >= expiration_date or delta.days <= threshold:
if verbose:
print('Renewing, expired or expires in', delta.days, 'days, less than', threshold)
renew_certificate(site, webroot, fqdns, site_path, admin_email, staging, verbose)
will_restart_daemons = True
if will_restart_daemons:
restart_daemons(daemons, verbose)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('-v', '--verbose', action='store_true', help='talk more')
parser.add_argument('-s', '--staging', action='store_true',
help='issue staging certificates (useful for testing purposes)')
parser.add_argument('-c', '--config', default=CONF_FILE,
help='path to a config file (default: {})'.format(CONF_FILE))
args = parser.parse_args()
config = yaml.load(open(args.config))
handle_certificates(config['certs_root'], config['www_root'], config['threshold'], config['daemons'],
config['admin_email'], staging=args.staging, verbose=args.verbose)
Configuration
Voici le format attendu pour la configuration.
certs_root:
/etc/letsencrypt/live
www_root:
/var/www
admin_email:
user@example.fr
threshold:
30
daemons:
- name:
apache2
action:
reload