Saltar a contenido

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 ECHConfig que provino de un resolvedor recursivo de confianza (TRR). Habilitar "DNS sobre HTTPS" no es suficiente por sí solo — hay que seleccionar un proveedor. Con network.trr.mode configurado pero network.trr.uri vací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. curl y openssl s_client en la misma máquina no se ven afectados, lo que hace que esto sea muy convincente como "bug del servidor".
  • Una entrada de hosts cubre el nombre. Un nombre resuelto de esa manera nunca obtiene su ECHConfig en 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 lleva ech= 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