NGINX, Let’s Encrypt et NTS#

Pourquoi#

Ce serveur a besoin d’un certificat TLS pour deux usages distincts : la page web publique de télémétrie (HTTPS classique) et l’authentification NTS (chapitre Chrony), qui réutilise le même certificat. Ce chapitre couvre l’émission du certificat via Let’s Encrypt, la configuration minimale de nginx pour la page publique, et surtout le mécanisme de rechargement du certificat côté chrony sans casser la synchronisation en cours.

Comment#

Émission du certificat#

apt install -y python3-venv libaugeas0
python3 -m venv /opt/certbot
/opt/certbot/bin/pip install --upgrade pip
/opt/certbot/bin/pip install certbot certbot-nginx
ln -sf /opt/certbot/bin/certbot /usr/bin/certbot

certbot --nginx \
    -d time.example.org \
    --key-type ecdsa --elliptic-curve secp256r1 \
    --agree-tos --email admin@example.org --no-eff-email

Vhost minimal pour la page de télémétrie#

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name time.example.org;

    root /var/www/html;
    index index.html;

    if ($request_method !~ ^(GET|HEAD)$ ) { return 405; }
    if ($http_user_agent ~* (wget|curl|scrapbot|zgrab)) { return 444; }

    location / { return 444; }
    location = /index.html { allow all; }
    location = /chrony_stats.txt { allow all; access_log off; }

    ssl_certificate     /etc/letsencrypt/live/time.example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/time.example.org/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305;

    # Groupes ECDHE/KEX TLS 1.3, hybride post-quantique en tête (chapitre
    # Post-quantique) ; retombe sur des courbes classiques si le client ne
    # le supporte pas.
    ssl_ecdh_curve      X25519MLKEM768:X25519:secp384r1:secp256r1;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "no-referrer" always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;
    add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
}
En-têteRôle
Strict-Transport-SecurityForce le HTTPS pour toute visite future, y compris sous-domaines
X-Frame-OptionsEmpêche d’encapsuler la page dans une <iframe> tierce (clickjacking)
X-Content-Type-OptionsEmpêche le navigateur de deviner un type MIME différent du déclaré
X-XSS-ProtectionFiltre XSS historique des navigateurs (redondant avec CSP sur les navigateurs récents, inoffensif de le garder)
Referrer-PolicyN’envoie jamais l’URL d’origine aux liens sortants
Permissions-PolicyDésactive explicitement les API navigateur sensibles (géolocalisation, caméra…) inutiles ici
Cross-Origin-*-PolicyIsole la page des interactions cross-origin non explicitement autorisées
Content-Security-PolicyRestreint les origines autorisées à charger scripts/styles/images, la ligne de défense la plus large contre l’injection de contenu

max-age=63072000 (2 ans, en secondes) associé à includeSubDomains et preload dépasse largement les conditions d’éligibilité à la liste de préchargement HSTS des navigateurs (Chrome, Firefox), qui n’exige en réalité qu’un an (max-age >= 31536000) : deux ans reste la pratique courante recommandée, une marge au-delà du minimum plutôt qu’une contrainte stricte.

La directive ssl_ecdh_curve place X25519MLKEM768 en tête de liste : un échange de clé hybride qui combine ML-KEM-768 (post-quantique, FIPS 203) et X25519 (classique), disponible nativement depuis OpenSSL 3.5. Le mécanisme de combinaison et sa justification sont détaillés au chapitre Post-quantique, qui couvre la même hybridation côté TLS et SSH. L’ANSSI recommande cette approche par hybridation dans sa fiche technique Transition post-quantique de TLS 1.3 (ANSSI-FT-115, février 2026), qui confirme cette implémentation native depuis OpenSSL 3.5.0.

Rechargement du certificat vers chrony, sans casser la PLL#

mkdir -p /etc/letsencrypt/renewal-hooks/post
cat > /etc/letsencrypt/renewal-hooks/post/copy.sh <<'EOF'
#!/bin/bash
set -e
SRC=/etc/letsencrypt/live/time.example.org
DST=/etc/chrony

cp "$SRC/fullchain.pem" "$DST/fullchain.pem"
cp "$SRC/privkey.pem"   "$DST/privkey.pem"
chmod 640 "$DST/fullchain.pem" "$DST/privkey.pem"
chown root:_chrony "$DST/fullchain.pem" "$DST/privkey.pem"

# SIGHUP recharge les certificats sans redémarrer le démon ni perturber
# la boucle de discipline d'horloge en cours.
if [ -f /run/chrony/chronyd.pid ]; then
    kill -HUP "$(cat /run/chrony/chronyd.pid)"
elif systemctl is-active --quiet chrony; then
    pkill -HUP -x chronyd
fi
EOF
chmod 755 /etc/letsencrypt/renewal-hooks/post/copy.sh
/etc/letsencrypt/renewal-hooks/post/copy.sh

Erreurs à éviter#

systemctl restart chrony casse la synchronisation en cours pour recharger un certificat, ne jamais faire ça. Un redémarrage complet du démon réinitialise la boucle de discipline d’horloge (PLL) : le serveur repart de zéro dans sa convergence, perdant potentiellement plusieurs minutes de précision optimale à chaque renouvellement de certificat (tous les 60-90 jours avec Let’s Encrypt). Le service Debian chrony.service ne définit d’ailleurs pas d’ExecReload=, systemctl reload chrony échoue directement avec “Job type reload is not applicable”. La bonne méthode est un signal SIGHUP envoyé directement au processus, qui recharge les fichiers cryptographiques sans perturber la synchronisation.

Privilégier ECDSA P-256 plutôt que RSA pour ce cas d’usage précis. Pas pour des raisons de sécurité (les deux offrent un niveau équivalent à taille de clé comparable) mais de performance : un certificat ECDSA fait environ 700 octets contre 2,5 Kio pour un RSA-4096 équivalent, et la latence de handshake TLS est significativement plus faible sur une plateforme ARM. Pour NTS, où l’échange de clés (NTS-KE) doit rester rapide, cette différence est loin d’être anecdotique.

  • OCSP stapling activé par erreur. Depuis mai 2025, Let’s Encrypt a cessé d’émettre l’URL OCSP dans les certificats, ssl_stapling on n’est plus qu’un avertissement silencieux dans les logs nginx, sans effet. Ne pas perdre de temps à déboguer une fonctionnalité dépréciée.

Vérifier#

nginx -t
systemctl reload nginx

# Vérifier le type de clé émis
openssl x509 -in /etc/letsencrypt/live/time.example.org/cert.pem -noout -text \
    | grep -A1 "Public Key Algorithm"
# Attendu : "Public Key Algorithm: id-ecPublicKey"

# Négociation TLS 1.3 effective
openssl s_client -connect time.example.org:443 -servername time.example.org </dev/null 2>/dev/null \
    | grep -i protocol

# Le groupe hybride post-quantique est bien proposé et négocié
openssl s_client -connect time.example.org:443 -groups X25519MLKEM768 </dev/null 2>/dev/null \
    | grep "Negotiated TLS1.3 group"
# Attendu : "Negotiated TLS1.3 group: X25519MLKEM768"

# Confirmer que chrony a bien rechargé le certificat après le hook
journalctl -u chrony --since "5 minutes ago" | grep -i cert

📚 Pour aller plus loin

TLS 1.3 est spécifié par la RFC 8446 (2018). NTS s’appuie dessus pour établir des clés à usage unique (RFC 8915, cf. chapitre Chrony) : c’est pourquoi le certificat TLS de la page web et celui utilisé par NTS-KE peuvent être le même fichier, la distinction se faisant au niveau du port (443 pour HTTPS, 4460 pour NTS-KE) et non du certificat lui-même.

  • L’ANSSI détaille la transition post-quantique du handshake TLS 1.3 dans sa fiche technique Transition post-quantique de TLS 1.3 (ANSSI-FT-115, février 2026) : hybridation de l’échange de clé (ML-KEM combiné à une courbe classique), disponible nativement dans OpenSSL depuis la version 3.5.0, et état de l’hybridation des signatures (ML-DSA, SLH-DSA), implémentée dans OpenSSL 3.5.0 mais pas encore utilisable dans le protocole TLS 1.3 lui-même.