ClientHello chiffré (ECH)
Encrypted Client Hello (RFC 9849) comble la dernière fuite majeure en clair dans une poignée de main TLS : l'indication du nom de serveur (Server Name Indication). Sans cela, chaque connexion TLS 1.3 annonce en clair le nom d'hôte que vous visitez, où tout observateur sur le chemin peut le lire. ECH enveloppe le vrai ClientHello — nom d'hôte compris — dans un ClientHello externe qui ne nomme qu'un nom d'hôte « de couverture » public et partagé.
ssl_ech_file et les variables $ssl_ech_status / $ssl_ech_outer_server_name
sont disponibles dans le paquet nginx standard, nginx-mod et edge —
toutes les builds GetPageSpeed sont liées à openssl35 (OpenSSL 3.5 LTS avec le
backport ECH). Aucun patch de NGINX n'est nécessaire ; les directives proviennent
de NGINX lui-même et s'activent lorsque la bibliothèque TLS prend en charge ECH.
Essayez avant de construire
ech-test.getpagespeed.com exécute
exactement cette pile sur un port 443 public, avec des clés rotatées
quotidiennement par le même paquet nginx-mod-ech documenté ci-dessous.
Il rapporte ce que votre propre navigateur a négocié, afin que vous puissiez
distinguer un problème DNS côté client d'un problème côté serveur avant de
toucher à votre propre configuration.
ECH vous aide-t-il vraiment ?
Soyez honnête avec vous-même sur le modèle de menace avant de le déployer.
ECH masque quel nom vous avez demandé parmi les noms partageant un serveur. Il ne masque pas que vous vous êtes connecté, et il ne masque pas l'adresse IP.
- Hébergement multi-locataires, CDN, frontaux partagés — gain réel et substantiel. Un observateur apprend que vous avez atteint un serveur hébergeant des milliers de sites et rien de plus.
- Un seul site sur une IP dédiée — très peu de gain. L'adresse seule identifie le site, et un DNS inversé ou une recherche de transparence des certificats termine le travail. ECH supprime toujours la chaîne SNI, ce qui a une certaine valeur contre la censure par correspondance de mots-clés grossière, mais ne le survendez pas.
La confidentialité d'ECH provient de la taille de l'ensemble d'anonymat derrière le nom public. Un nom de couverture utilisé par un seul site ne protège rien.
Configuration
server {
listen 443 ssl;
listen 443 quic;
http2 on;
http3 on;
server_name secret.example.com;
ssl_certificate /etc/pki/tls/certs/secret.example.com.crt;
ssl_certificate_key /etc/pki/tls/private/secret.example.com.key;
ssl_protocols TLSv1.3;
ssl_ech_file /etc/nginx/ech/ech-20260825T000000Z.pem;
}
ECH est inerte tant que ssl_ech_file n'est pas présent, donc l'ajout du paquet
ne change rien à un déploiement existant.
ssl_ech_file est valide dans les contextes http et server. Le placer au
niveau http l'applique à chaque serveur TLS qui en hérite, ce qui est normalement
ce que vous voulez — et c'est inoffensif pour les serveurs HTTP simples, car NGINX
ne lit les clés ECH que pour les serveurs qui ont des certificats.
Plusieurs clés, et pourquoi l'ordre compte
ssl_ech_file peut être répété. L'ordre n'est pas cosmétique :
ssl_ech_file /etc/nginx/ech/ech-20260825T000000Z.pem; # annoncé
ssl_ech_file /etc/nginx/ech/ech-20260824T000000Z.pem; # déchiffrement uniquement
ssl_ech_file /etc/nginx/ech/ech-20260823T000000Z.pem; # déchiffrement uniquement
NGINX annonce uniquement le premier fichier dans les retry-configs ECH.
Chaque fichier suivant est chargé pour le déchiffrement uniquement. C'est ce qui
rend la rotation des clés sûre : un client qui a récupéré une ECHConfigList
plus ancienne depuis un enregistrement HTTPS en cache a chiffré vers une clé plus
ancienne, et cette clé doit encore être dans le stockage sinon la poignée de main
échoue.
Les clés sont lues lorsque NGINX analyse sa configuration, donc une clé
nouvellement générée ne fait rien jusqu'à nginx -s reload.
Le nom public a besoin d'un certificat
Le public_name intégré dans l'ECHConfig est le nom d'hôte de couverture que les
clients placent dans le SNI externe en clair. Choisissez-en un que vous contrôlez,
pointez-le vers le même serveur, et assurez-vous que votre certificat le couvre.
Lorsqu'une tentative ECH d'un client échoue — clé obsolète, enregistrement
corrompu, middlebox — il retombe sur une poignée de main ordinaire contre ce nom,
et une erreur de certificat à cet endroit est un échec dur que vos utilisateurs
verront.
L'observer
log_format ech '$remote_addr "$host" ech=$ssl_ech_status '
'outer=$ssl_ech_outer_server_name';
access_log /var/log/nginx/access.log ech;
$ssl_ech_status rapporte le résultat de la tentative ECH pour la connexion.
$ssl_ech_outer_server_name donne le nom de couverture utilisé par le client, ce
qui est utile pour confirmer que le client utilise vraiment votre ECHConfig plutôt
que GREASE.
Publication de l'enregistrement DNS HTTPS
ECH est sans valeur tant que les clients ne peuvent pas trouver votre
ECHConfigList. Elle voyage dans le paramètre ech= d'un
enregistrement de ressource HTTPS :
secret.example.com. 300 IN HTTPS 1 . alpn="h3,h2" ech="AD7+DQA65wAg..."
nginx-ech-keygen --print-dns affiche la valeur exacte ech="..." pour la clé
actuellement annoncée.
Deux exigences que les gens comprennent mal
La zone doit être DNS-only. Si l'enregistrement est derrière un CDN proxy —
un enregistrement Cloudflare orange-cloud, par exemple — ce fournisseur termine
TLS et publie son propre enregistrement HTTPS avec ses propres clés ECH. Les
clients obtiennent l'ECH du fournisseur, pas le vôtre ; votre ssl_ech_file n'est
jamais utilisé, car le fournisseur est le point de terminaison TLS. Ce n'est pas
nécessairement mauvais (l'ensemble d'anonymat du fournisseur est énorme), mais
c'est le leur, pas le vôtre. Pour servir votre propre ECH, l'enregistrement doit
être grey-cloud / DNS-only.
Les clients ont besoin d'un DNS chiffré. Un enregistrement HTTPS récupéré sur le port 53 en clair fuit le nom d'hôte exactement à l'observateur qu'ECH est censé déjouer, donc les navigateurs n'utilisent ECH que lorsque l'enregistrement est arrivé via DoH ou DoT. Les utilisateurs sans DNS chiffré n'obtiennent pas ECH — rien de ce que vous configurez sur le serveur ne change cela.
Exemple Cloudflare
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data @- <<EOF
{
"type": "HTTPS",
"name": "secret.example.com",
"ttl": 300,
"proxied": false,
"data": {
"priority": 1,
"target": ".",
"value": "alpn=\"h3,h2\" $(nginx-ech-keygen --print-dns)"
}
}
EOF
Gardez le TTL court et confortablement en dessous de votre fenêtre de rétention
des clés, afin qu'une clé retirée ne soit jamais la seule vers laquelle pointe un
enregistrement en cache. Vous n'avez besoin de créer l'enregistrement à la main
qu'une seule fois — ensuite, nginx-ech-publish le maintient à jour à chaque
rotation (voir ci-dessous).
Rotation automatique des clés
Les clés ECH sont conçues pour être de courte durée. L'outillage de rotation est
fourni avec chaque build : nginx-ech pour le paquet nginx standard,
nginx-mod-ech pour NGINX-MOD :
dnf install nginx-ech # nginx standard
dnf install nginx-mod-ech # NGINX-MOD
Il fournit nginx-ech-keygen, un minuteur systemd nginx-ech-rotate, et
/etc/sysconfig/nginx-ech-rotate. Rien ne s'exécute tant que vous ne l'avez pas
configuré.
# 1. Définissez le nom d'hôte de couverture.
vi /etc/sysconfig/nginx-ech-rotate # ECH_PUBLIC_NAME="ech.example.com"
# 2. Créez la première clé. Écrit /etc/nginx/conf.d/ech-keys.conf et recharge.
nginx-ech-keygen --init
# 3. Publiez ce qu'il affiche dans votre enregistrement HTTPS.
nginx-ech-keygen --print-dns
# 4. Laissez la rotation republier le DNS elle-même (Cloudflare fourni en premier ;
# voir « Garder le DNS en phase avec la rotation » ci-dessous).
vi /etc/sysconfig/nginx-ech-publish # ECH_PUBLISH_PROVIDER="cloudflare"
# 5. Confiez la rotation au minuteur (quotidien par défaut).
systemctl enable --now nginx-ech-rotate.timer
| Commande | Effet |
|---|---|
--init |
Première clé, génère l'inclusion, recharge NGINX |
--rotate |
Nouvelle clé, retire tout ce qui dépasse ECH_RETAIN, recharge |
--print-dns |
La valeur ech="..." pour la clé annoncée |
--list |
Clés actuelles, marquées annoncée / déchiffrement uniquement |
Sur EDGE
Le même outillage est fourni sous le nom edge-ech, rebaptisé avec les
chemins propres à EDGE : edge-ech-keygen, le minuteur edge-ech-rotate,
/etc/sysconfig/edge-ech-rotate, et les clés sous /etc/edge/ech. Chaque
étape ci-dessus s'applique en substituant les noms.
ECH_RETAIN (défaut 3) contrôle combien de générations restent chargées. Avec une
rotation quotidienne, cela représente environ une fenêtre de grâce de trois jours
pour les clients détenant un enregistrement HTTPS en cache. Réglez-le au-dessus du
TTL de votre enregistrement, pas en dessous.
Garder le DNS en phase avec la rotation
La rotation seule laisse le DNS en arrière : la ECHConfigList vers laquelle un
client chiffre provient de l'enregistrement HTTPS, pas du serveur, donc
l'enregistrement devient obsolète dès la première fois que le minuteur se
déclenche. Rien ne casse — les clients détenant la valeur obsolète obtiennent
ECH: failed+retry-configs, se connectent toujours via le nom de couverture, et
s'auto-réparent grâce au retry-config. Mais aucun premier handshake n'obtient
jamais ECH, ce qui jette silencieusement la majeure partie du bénéfice.
nginx-ech-publish ferme cette boucle (fourni dans nginx-mod-ech depuis
1.30.4-61 et edge-ech depuis 1.30.4-6 ; pas encore dans le paquet nginx-ech
standard). Il s'exécute comme deuxième ExecStart du service oneshot
nginx-ech-rotate.service, donc il ne se déclenche qu'après une rotation réussie :
il lit la valeur annoncée depuis --print-dns (la ECHConfigList publique
uniquement, jamais la clé privée) et la republie dans l'enregistrement HTTPS. Il
est fourni inerte — sans fournisseur configuré, il se termine sans rien faire. Une
modification de sysconfig l'active :
# /etc/sysconfig/nginx-ech-publish
ECH_PUBLISH_PROVIDER="cloudflare"
ECH_ZONE_ID="..." # ID de zone depuis la page de vue d'ensemble du domaine
ECH_RECORD_NAME="www.example.com" # le nom interne que les clients visitent, PAS le nom de couverture
| Variable | Signification |
|---|---|
ECH_PUBLISH_PROVIDER |
Fournisseur à exécuter ; vide signifie ne rien faire |
ECH_ZONE_ID |
Identifiant de zone du fournisseur |
ECH_RECORD_NAME |
FQDN de l'enregistrement HTTPS — le nom interne activé ECH |
ECH_ALPN |
Le SvcParam alpn= annoncé avec ech= (défaut h3,h2) |
ECH_TTL |
TTL de l'enregistrement en secondes (défaut 300) |
ECH_PUBLISH_CREDENTIALS |
Fichier d'identifiants (défaut /root/.cloudflare.ini) |
Le fournisseur Cloudflare lit le format ini dns-cloudflare de certbot
(dns_cloudflare_email / dns_cloudflare_api_key), donc un fichier
d'identifiants certbot existant est réutilisé plutôt que copié. Il met à jour
l'enregistrement en place, et un échec de recherche d'enregistrement est un arrêt
dur, jamais traité comme « pas d'enregistrement » — il ne peut donc pas créer
d'enregistrements HTTPS en double.
Les fournisseurs sont des exécutables drop-in sous /usr/libexec/nginx-ech-publish/.
Le répartiteur exporte ECH_B64, ECH_ZONE_ID, ECH_RECORD_NAME,
ECH_ALPN, ECH_TTL et ECH_PUBLISH_CREDENTIALS dans l'environnement du
fournisseur, donc prendre en charge Route 53, deSEC ou toute autre API DNS est un
script déposé dans ce répertoire — aucune modification du répartiteur.
Pour un fournisseur que vous scriptez vous-même en dehors de ce mécanisme, le hook
à l'ancienne fonctionne toujours : ajoutez votre propre drop-in ExecStart à
nginx-ech-rotate.service, lisez nginx-ech-keygen --print-dns, et faites un
PUT de la valeur dans l'enregistrement HTTPS. Dans les deux cas, la rotation
conserve ECH_RETAIN générations, donc l'écart entre le rechargement et la
propagation DNS est couvert par conception — la clé précédente est toujours
chargée pour le déchiffrement.
Certificats : ne mettez pas le nom interne sur le nom de couverture
Le certificat du nom de couverture est présenté dans la poignée de main externe, où l'observateur qu'ECH existe pour déjouer peut le lire. Si une seule liste SAN couvre à la fois le nom de couverture et les noms que vous cachez, ce certificat rend exactement ce qu'ECH vient de dissimuler.
Délivrez-les séparément : un certificat pour le nom public, un par nom interne.
Ce que coûte réellement une fenêtre trop courte
Pas une panne, en fin de compte. Nous avons mesuré les trois cas avec ces paquets :
ECHConfigList en cache du client |
Résultat |
|---|---|
| La clé annoncée | ECH réussit, requête routée vers le nom interne |
| Une clé conservée en déchiffrement uniquement | ECH réussit, requête routée vers le nom interne |
Expirée de ECH_RETAIN |
ECH: failed+retry-configs, la connexion aboutit quand même |
Dans le troisième cas, NGINX ne peut pas déchiffrer, donc il retombe sur le ClientHello externe, sert le bloc serveur du nom public, et renvoie un retry-config portant la clé actuelle. Le client le récupère et se rétablit tout seul. Le coût est un aller-retour gaspillé, pas une page cassée.
C'est aussi pourquoi le certificat du nom public compte tellement : chaque client obsolète y atterrit. Obtenez ce certificat de travers et une rotation transforme une nouvelle tentative récupérable en erreur de certificat.
/etc/nginx/conf.d/ech-keys.conf est généré à chaque rotation. Ne le modifiez
pas ; il est réécrit pour correspondre à ce qui est sur le disque. Les clés vivent
dans /etc/nginx/ech, mode 0640 root:nginx, dans un répertoire 0750 — le
maître NGINX analyse la configuration en tant que root, donc les workers n'ont
jamais besoin de les lire.
Rotater plus souvent que quotidiennement est raisonnable et c'est ce que les
concepteurs d'ECH suggèrent, mais rappelez-vous que chaque rotation coûte un
nginx -s reload. Remplacez avec un drop-in plutôt qu'en éditant l'unité fournie :
systemctl edit nginx-ech-rotate.timer # [Timer] / OnCalendar=hourly
Support client
Vérifié contre ces paquets, de bout en bout, via DoH contre un véritable
enregistrement HTTPS ech=, atteignant $ssl_ech_status en succès :
| Client | Résultat |
|---|---|
| Chrome / Chromium | ECH négocié |
| Firefox 147 | ECH négocié |
openssl35 s_client -ech_config_list |
ECH négocié |
Les clients qui ne prennent pas en charge ECH ne sont pas affectés : ils envoient un ClientHello normal et NGINX les sert normalement.
Vous pouvez vérifier n'importe quel client contre notre hôte de démonstration public, ech-test.getpagespeed.com — il rapporte ce que votre navigateur a réellement négocié, et sert les mêmes données en JSON à /status.json.
Quand un navigateur qui prend en charge ECH ne l'utilise pas
Presque toujours du DNS, pas du TLS. Firefox en particulier refuse d'utiliser ECH dans plusieurs situations dans lesquelles une configuration de test tombe facilement, et chacune ressemble à un échec d'interopérabilité alors que ce n'en est pas un. Le nôtre a rapporté GREASE pendant un moment exactement pour cette raison.
- L'enregistrement HTTPS n'a pas été récupéré via DoH. Firefox n'utilise un
ECHConfigque s'il provient d'un résolveur récursif de confiance (TRR). Activer « DNS over HTTPS » ne suffit pas en soi — un fournisseur doit être sélectionné. Avecnetwork.trr.modedéfini maisnetwork.trr.urivide, Firefox résout nativement et envoie GREASE. - Un proxy système fait obstacle. Firefox honore la configuration de proxy du
système par défaut, y compris un fichier PAC. Si DoH est routé via ce proxy et
que le proxy ne le transporte pas, TRR échoue silencieusement et chaque
recherche retombe sur le DNS natif — ou se bloque.
curletopenssl s_clientsur la même machine ne sont pas affectés, ce qui rend ce cas très convaincant comme « bug serveur ». - Une entrée
hostscouvre le nom. Un nom résolu de cette façon ne voit jamais sonECHConfigrécupéré du tout. - L'adresse est RFC 1918. Les réponses TRR contenant des adresses privées
sont rejetées par défaut (
network.trr.allow-rfc1918) comme mesure anti-détournement DNS — ce qui rejette l'enregistrement HTTPS portantech=avec elles. - Sur macOS, DoH doit être activé pour qu'ECH soit utilisé du tout.
Testez contre un nom résoluble publiquement pointant vers une adresse publique,
avec un fournisseur DoH explicitement sélectionné, pas de proxy, et pas d'entrée
hosts. about:networking#dns montre si la réponse provient de TRR.
Limitations
- Le mode split n'est pas pris en charge. NGINX implémente le serveur en mode partagé, où le serveur terminant ECH sert également le nom interne. Le mode split, dans lequel un frontal déchiffre ECH et transmet à un backend séparé, n'est pas dans NGINX upstream.
- ECH nécessite TLS 1.3. Il n'y a pas d'ECH pour les connexions TLS 1.2.
- Un rechargement est requis pour que les nouvelles clés prennent effet.
Voir aussi
- HTTP/3 (QUIC) — ECH se marie naturellement avec HTTP/3
- NGINX-MOD — la build améliorée dans laquelle ces directives sont également fournies
- RFC 9849 — TLS Encrypted Client Hello
- RFC 9460 — HTTPS and SVCB resource records