Encrypted Client Hello (ECH)
Encrypted Client Hello (RFC 9849) cierra la última gran fuga de texto plano en un handshake TLS: la Server Name Indication. Sin ella, cada conexión TLS 1.3 anuncia el nombre de host que estás visitando en claro, donde cualquier observador en la ruta puede leerlo. ECH envuelve el ClientHello real — nombre de host y todo — dentro de uno externo que solo nombra un nombre de host "tapadera" público y compartido.
ssl_ech_file y las variables $ssl_ech_status / $ssl_ech_outer_server_name
están disponibles en el paquete estándar nginx, nginx-mod y edge —
todas las compilaciones de GetPageSpeed enlazan contra openssl35 (OpenSSL 3.5 LTS con el
backport de ECH). No se requiere parchear NGINX; las directivas provienen
del propio NGINX y se activan cuando la librería TLS soporta ECH.
Pruébalo antes de construirlo
ech-test.getpagespeed.com ejecuta este
mismo stack en un puerto 443 público, con claves rotadas diariamente por el
mismo paquete nginx-mod-ech documentado abajo. Informa qué negoció tu propio
navegador, para que puedas distinguir un problema DNS del lado del cliente de uno
del lado del servidor antes de tocar tu propia configuración.
¿ECH realmente te ayuda?
Sé honesto contigo mismo sobre el modelo de amenaza antes de implementarlo.
ECH oculta qué nombre pediste entre los nombres que comparten un servidor. No oculta que te conectaste, y no oculta la dirección IP.
- Hosting multi-tenant, CDNs, front ends compartidos — ganancia real y sustancial. Un observador aprende que llegaste a un servidor que aloja miles de sitios y nada más.
- Un solo sitio en una IP dedicada — muy poca ganancia. La dirección por sí sola identifica el sitio, y un DNS inverso o una consulta de transparencia de certificados termina el trabajo. ECH aún elimina la cadena SNI, lo cual vale algo contra la censura por coincidencia de palabras clave, pero no lo vendas de más.
La privacidad de ECH proviene del tamaño del conjunto de anonimato detrás del nombre público. Un nombre tapadera usado por un solo sitio no protege nada.
Configuración
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 hasta que ssl_ech_file está presente, así que añadir el paquete no
cambia nada en una implementación existente.
ssl_ech_file es válido tanto en el contexto http como en server. Ponerlo a nivel
http lo aplica a cada servidor TLS que lo hereda, que es normalmente lo que
quieres — y es inofensivo para servidores HTTP plano, porque NGINX solo
lee las claves ECH para servidores que tienen certificados.
Varias claves, y por qué el orden importa
ssl_ech_file puede repetirse. El orden no es cosmético:
ssl_ech_file /etc/nginx/ech/ech-20260825T000000Z.pem; # anunciada
ssl_ech_file /etc/nginx/ech/ech-20260824T000000Z.pem; # solo descifrado
ssl_ech_file /etc/nginx/ech/ech-20260823T000000Z.pem; # solo descifrado
NGINX anuncia solo el primer archivo en las retry-configs de ECH. Cada archivo posterior
se carga solo para descifrado. Eso es lo que hace segura la rotación de claves: un cliente
que obtuvo un ECHConfigList antiguo de un registro HTTPS en caché cifró hacia una
clave antigua, y esa clave debe seguir en el almacén o el handshake falla.
Las claves se leen cuando NGINX analiza su configuración, así que una clave recién
generada no hace nada hasta nginx -s reload.
El nombre público necesita un certificado
El public_name incluido en el ECHConfig es el nombre de host tapadera que los clientes ponen
en el SNI externo en claro. Elige uno que controles, apúntalo al mismo servidor,
y asegúrate de que tu certificado lo cubre. Cuando el intento ECH de un cliente falla —
clave obsoleta, registro dañado, middlebox — cae a un handshake ordinario
contra ese nombre, y un error de certificado allí es un fallo duro que tus usuarios
verán.
Observándolo
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 informa el resultado del intento ECH para la conexión.
$ssl_ech_outer_server_name da el nombre tapadera que usó el cliente, lo cual es
útil para confirmar que el cliente realmente está usando tu ECHConfig en lugar de
GREASE.
Publicando el registro DNS HTTPS
ECH no vale nada hasta que los clientes puedan encontrar tu ECHConfigList. Viaja en el
parámetro ech= de un registro de recurso HTTPS:
secret.example.com. 300 IN HTTPS 1 . alpn="h3,h2" ech="AD7+DQA65wAg..."
nginx-ech-keygen --print-dns imprime el valor exacto de ech="..." para la
clave actualmente anunciada.
Dos requisitos que la gente entiende mal
La zona debe ser solo DNS. Si el registro está detrás de un CDN con proxy — un
registro Cloudflare de nube naranja, por ejemplo — ese proveedor termina TLS y
publica su propio registro HTTPS con sus propias claves ECH. Los clientes obtienen el
ECH del proveedor, no el tuyo; tu ssl_ech_file nunca se ejercita, porque el
proveedor es el endpoint TLS. Eso no es necesariamente malo (el conjunto de
anonimato del proveedor es enorme), pero es de ellos, no tuyo. Para servir tu propio ECH,
el registro debe ser de nube gris / solo DNS.
Los clientes necesitan DNS cifrado. Un registro HTTPS obtenido a través del puerto 53 en texto plano filtra el nombre de host exactamente al observador que ECH pretende derrotar, así que los navegadores solo usan ECH cuando el registro llegó a través de DoH o DoT. Los usuarios sin DNS cifrado no obtienen ECH — nada de lo que configures en el servidor cambia eso.
Ejemplo con 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
Mantén el TTL corto y cómodamente por debajo de tu ventana de retención de claves, para que
una clave rotada nunca sea la única a la que apunte un registro en caché. Solo necesitas
crear el registro manualmente una vez — después, nginx-ech-publish lo mantiene
actualizado en cada rotación (ver abajo).
Rotación automática de claves
Las claves ECH están pensadas para ser de corta duración. Las herramientas de rotación se
incluyen con cada compilación: nginx-ech para el paquete estándar nginx, nginx-mod-ech para
NGINX-MOD:
dnf install nginx-ech # nginx estándar
dnf install nginx-mod-ech # NGINX-MOD
Proporciona nginx-ech-keygen, un temporizador systemd nginx-ech-rotate, y
/etc/sysconfig/nginx-ech-rotate. Nada se ejecuta hasta que lo configures.
# 1. Establece el nombre de host tapadera.
vi /etc/sysconfig/nginx-ech-rotate # ECH_PUBLIC_NAME="ech.example.com"
# 2. Crea la primera clave. Escribe /etc/nginx/conf.d/ech-keys.conf y recarga.
nginx-ech-keygen --init
# 3. Publica lo que imprime en tu registro HTTPS.
nginx-ech-keygen --print-dns
# 4. Deja que la rotación republica DNS por sí misma (Cloudflare incluido primero; ver
# "Mantén el DNS en sintonía con la rotación" abajo).
vi /etc/sysconfig/nginx-ech-publish # ECH_PUBLISH_PROVIDER="cloudflare"
# 5. Entrega la rotación al temporizador (diario por defecto).
systemctl enable --now nginx-ech-rotate.timer
| Comando | Efecto |
|---|---|
--init |
Primera clave, genera el include, recarga NGINX |
--rotate |
Nueva clave, retira cualquier cosa más allá de ECH_RETAIN, recarga |
--print-dns |
El valor de ech="..." para la clave anunciada |
--list |
Claves actuales, marcadas anunciada / solo descifrado |
En EDGE
Las mismas herramientas se incluyen como edge-ech, renombradas a las rutas propias de EDGE:
edge-ech-keygen, el temporizador edge-ech-rotate,
/etc/sysconfig/edge-ech-rotate, y claves bajo /etc/edge/ech. Cada
paso anterior aplica con los nombres sustituidos.
ECH_RETAIN (por defecto 3) controla cuántas generaciones permanecen cargadas. Con rotación
diaria eso es aproximadamente una ventana de gracia de tres días para clientes que tienen un registro
HTTPS en caché. Ponlo por encima del TTL de tu registro, no por debajo.
Mantén el DNS en sintonía con la rotación
La rotación por sí sola deja el DNS atrás: el ECHConfigList al que un cliente cifra
proviene del registro HTTPS, no del servidor, así que el registro se vuelve obsoleto la primera
vez que el temporizador se dispara. Nada se rompe — los clientes con el valor
obsoleto obtienen ECH: failed+retry-configs, aún se conectan a través del nombre tapadera, y
se auto-curan con la retry-config. Pero ningún primer handshake obtiene ECH,
lo que silenciosamente tira la mayor parte del beneficio.
nginx-ech-publish cierra ese ciclo (incluido en nginx-mod-ech desde
1.30.4-61 y edge-ech desde 1.30.4-6; aún no en el paquete estándar
nginx-ech). Se ejecuta como el segundo ExecStart del
oneshot nginx-ech-rotate.service, así que se dispara solo después de una rotación
exitosa: lee el valor anunciado de --print-dns (el ECHConfigList público
solo, nunca la clave privada) y lo republica en el registro HTTPS. Se incluye inerte — sin
proveedor configurado sale sin hacer nada. Una edición de sysconfig lo activa:
# /etc/sysconfig/nginx-ech-publish
ECH_PUBLISH_PROVIDER="cloudflare"
ECH_ZONE_ID="..." # ID de zona de la página de resumen del dominio
ECH_RECORD_NAME="www.example.com" # el nombre interno que visitan los clientes, NO el nombre tapadera
| Variable | Significado |
|---|---|
ECH_PUBLISH_PROVIDER |
Drop-in del proveedor a ejecutar; vacío significa no hacer nada |
ECH_ZONE_ID |
Identificador de zona del proveedor |
ECH_RECORD_NAME |
FQDN del registro HTTPS — el nombre interno habilitado para ECH |
ECH_ALPN |
El SvcParam alpn= anunciado junto a ech= (por defecto h3,h2) |
ECH_TTL |
TTL del registro en segundos (por defecto 300) |
ECH_PUBLISH_CREDENTIALS |
Archivo de credenciales (por defecto /root/.cloudflare.ini) |
El proveedor de Cloudflare lee el formato ini de dns-cloudflare de certbot
(dns_cloudflare_email / dns_cloudflare_api_key), así que un archivo de credenciales
existente de certbot se reutiliza en lugar de copiarse. Actualiza el registro en su lugar,
y una búsqueda de registro fallida es una parada dura, nunca se trata como "sin registro" — así que
no puede crear registros HTTPS duplicados.
Los proveedores son ejecutables drop-in bajo /usr/libexec/nginx-ech-publish/.
El despachador exporta ECH_B64, ECH_ZONE_ID, ECH_RECORD_NAME,
ECH_ALPN, ECH_TTL y ECH_PUBLISH_CREDENTIALS al entorno del
proveedor, así que soportar Route 53, deSEC o cualquier otra API DNS es un
script colocado en ese directorio — sin cambios en el despachador.
Para un proveedor que escribas tú mismo fuera de ese mecanismo, el hook
a la antigua aún funciona: añade tu propio drop-in ExecStart a
nginx-ech-rotate.service, lee nginx-ech-keygen --print-dns, y haz PUT
del valor en el registro HTTPS. De cualquier manera, la rotación retiene ECH_RETAIN
generaciones, así que la brecha entre la recarga y la propagación DNS está cubierta por
diseño — la clave anterior aún está cargada para descifrado.
Certificados: no pongas el nombre interno en el nombre tapadera
El certificado del nombre tapadera se presenta en el handshake externo, donde el observador que ECH existe para derrotar puede leerlo. Si una lista SAN cubre tanto el nombre tapadera como los nombres que estás ocultando, ese certificado devuelve exactamente lo que ECH acaba de ocultar.
Emítelos por separado: un certificado para el nombre público, uno por cada nombre interno.
Lo que realmente cuesta una ventana demasiado corta
No es una caída, resulta ser. Medimos los tres casos contra estos paquetes:
ECHConfigList en caché del cliente |
Resultado |
|---|---|
| La clave anunciada | ECH tiene éxito, la solicitud se enruta al nombre interno |
| Una clave retenida solo de descifrado | ECH tiene éxito, la solicitud se enruta al nombre interno |
Fuera de ECH_RETAIN |
ECH: failed+retry-configs, la conexión aún se completa |
En el tercer caso NGINX no puede descifrar, así que cae al ClientHello externo, sirve el bloque de servidor del nombre público, y devuelve una retry-config con la clave actual. El cliente la recoge y se recupera por sí solo. El costo es un viaje de ida y vuelta desperdiciado, no una página rota.
Esto también es por qué el certificado del nombre público importa tanto: cada cliente obsoleto aterriza en él. Si te equivocas con ese certificado, una rotación convierte un reintento recuperable en un error de certificado.
/etc/nginx/conf.d/ech-keys.conf se genera en cada rotación. No lo edites;
se reescribe para coincidir con lo que hay en disco. Las claves viven en
/etc/nginx/ech, modo 0640 root:nginx, en un directorio 0750 — el maestro
de NGINX analiza la configuración como root, así que los workers nunca necesitan leerlas.
Rotar más a menudo que diariamente es razonable y es lo que los diseñadores de ECH
sugieren, pero recuerda que cada rotación cuesta un nginx -s reload. Anula con un
drop-in en lugar de editar la unidad incluida:
systemctl edit nginx-ech-rotate.timer # [Timer] / OnCalendar=hourly
Soporte de clientes
Verificado contra estos paquetes, de extremo a extremo, a través de DoH contra un registro
ech= real, alcanzando éxito en $ssl_ech_status:
| Cliente | Resultado |
|---|---|
| Chrome / Chromium | ECH negociado |
| Firefox 147 | ECH negociado |
openssl35 s_client -ech_config_list |
ECH negociado |
Los clientes que no soportan ECH no se ven afectados: envían un ClientHello normal y NGINX los sirve normalmente.
Puedes comprobar cualquier cliente contra nuestro host de demostración público, ech-test.getpagespeed.com — informa lo que tu navegador realmente negoció, y sirve los mismos datos como JSON en /status.json.
Cuando un navegador que soporta ECH no lo usa
Casi siempre es DNS, no TLS. Firefox en particular rechaza usar ECH en varias situaciones en las que una configuración de prueba cae fácilmente, y cada una parece un fallo de interoperabilidad sin serlo en absoluto. El nuestro informó GREASE por un tiempo exactamente por esta razón.
- El registro HTTPS no se obtuvo a través de DoH. Firefox solo usa un
ECHConfigque provino de un resolvedor recursivo de confianza (TRR). Habilitar "DNS sobre HTTPS" no es suficiente por sí solo — hay que seleccionar un proveedor. Connetwork.trr.modeconfigurado peronetwork.trr.urivacío, Firefox resuelve nativamente y envía GREASE. - Un proxy del sistema está en el medio. Firefox honra la configuración de proxy del SO
por defecto, incluyendo un archivo PAC. Si DoH se enruta a través de ese proxy y el
proxy no lo transporta, TRR falla silenciosamente y cada búsqueda cae a
DNS nativo — o se cuelga.
curlyopenssl s_clienten la misma máquina no se ven afectados, lo que hace que esto sea muy convincente como "bug del servidor". - Una entrada de
hostscubre el nombre. Un nombre resuelto de esa manera nunca obtiene suECHConfigen absoluto. - La dirección es RFC 1918. Las respuestas TRR que contienen direcciones privadas se
descartan por defecto (
network.trr.allow-rfc1918) como medida anti-DNS-rebinding — lo que descarta el registro HTTPS que llevaech=junto con ellas. - En macOS, DoH debe estar activado para que ECH se use en absoluto.
Prueba contra un nombre resoluble públicamente que apunte a una dirección pública, con un
proveedor DoH seleccionado explícitamente, sin proxy, y sin entrada de hosts.
about:networking#dns muestra si la respuesta vino de TRR.
Limitaciones
- El modo split no está soportado. NGINX implementa el servidor de modo compartido, donde el servidor que termina ECH también sirve el nombre interno. El modo split, en el que un front end descifra ECH y reenvía a un backend separado, no está en NGINX upstream.
- ECH necesita TLS 1.3. No hay ECH para conexiones TLS 1.2.
- Se requiere una recarga para que las nuevas claves surtan efecto.
Ver también
- HTTP/3 (QUIC) — ECH se combina naturalmente con HTTP/3
- NGINX-MOD — la compilación mejorada en la que también se incluyen estas directivas
- RFC 9849 — TLS Encrypted Client Hello
- RFC 9460 — HTTPS and SVCB resource records