Aller au contenu

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 ECHConfig que 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é. Avec network.trr.mode défini mais network.trr.uri vide, 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. curl et openssl s_client sur la même machine ne sont pas affectés, ce qui rend ce cas très convaincant comme « bug serveur ».
  • Une entrée hosts couvre le nom. Un nom résolu de cette façon ne voit jamais son ECHConfig ré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 portant ech= 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