Client Hello Criptografado (ECH)
O Client Hello Criptografado (RFC 9849) fecha a última grande lacuna de texto simples em um handshake TLS: a Indicação de Nome de Servidor (Server Name Indication). Sem ela, toda conexão TLS 1.3 anuncia o nome do host que você está visitando em texto claro, onde qualquer observador no caminho pode lê-lo. O ECH envolve o ClientHello real — nome do host e tudo mais — dentro de um externo que nomeia apenas um nome de "cobertura" público e compartilhado.
As variáveis ssl_ech_file e $ssl_ech_status / $ssl_ech_outer_server_name
estão disponíveis no pacote nginx padrão, nginx-mod e edge —
todas as builds GetPageSpeed são vinculadas ao openssl35 (OpenSSL 3.5 LTS com o
backport do ECH). Não há necessidade de aplicar patches no NGINX; as diretivas vêm do
próprio NGINX e são ativadas quando a biblioteca TLS suporta ECH.
Experimente antes de implementar
ech-test.getpagespeed.com executa esta
mesma stack em uma porta 443 pública, com chaves rotacionadas diariamente pelo mesmo
pacote nginx-mod-ech documentado abaixo. Ele informa o que o seu próprio navegador
negociou, para que você possa distinguir um problema de DNS no lado do cliente de um
problema no lado do servidor antes de mexer na sua própria configuração.
O ECH realmente ajuda você?
Seja honesto consigo mesmo sobre o modelo de ameaça antes de implementá-lo.
O ECH oculta qual nome você solicitou entre os nomes que compartilham um servidor. Ele não oculta que você se conectou e não oculta o endereço IP.
- Hospedagem multi-tenant, CDNs, front ends compartilhados — ganho real e substancial. Um observador descobre que você acessou um servidor que hospeda milhares de sites e nada mais.
- Um único site em um IP dedicado — ganho muito pequeno. O endereço sozinho identifica o site, e o DNS reverso ou uma consulta de transparência de certificado completa o trabalho. O ECH ainda remove a string SNI, o que vale algo contra censura grosseira por correspondência de palavras-chave, mas não superestime isso.
A privacidade do ECH vem do tamanho do conjunto de anonimato por trás do nome público. Um nome de cobertura usado por um único site não protege nada.
Configuração
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;
}
O ECH fica inerte até que ssl_ech_file esteja presente, portanto adicionar o pacote não
altera nada em uma implantação existente.
ssl_ech_file é válido tanto no contexto http quanto no server. Colocá-lo no
nível http o aplica a todos os servidores TLS que o herdam, o que normalmente é
o que você deseja — e é inofensivo para servidores HTTP simples, porque o NGINX só
lê chaves ECH para servidores que possuem certificados.
Várias chaves e por que a ordem importa
ssl_ech_file pode ser repetido. A ordem não é cosmética:
ssl_ech_file /etc/nginx/ech/ech-20260825T000000Z.pem; # anunciada
ssl_ech_file /etc/nginx/ech/ech-20260824T000000Z.pem; # somente descriptografia
ssl_ech_file /etc/nginx/ech/ech-20260823T000000Z.pem; # somente descriptografia
O NGINX anuncia apenas o primeiro arquivo nas retry-configs do ECH. Todos os arquivos
posteriores são carregados apenas para descriptografia. É isso que torna a rotação de chaves segura: um
cliente que obteve um ECHConfigList mais antigo de um registro HTTPS em cache criptografado com uma
chave mais antiga, e essa chave ainda deve estar no armazenamento ou o handshake falha.
As chaves são lidas quando o NGINX analisa sua configuração, portanto uma chave recém-gerada
não faz nada até nginx -s reload.
O nome público precisa de um certificado
O public_name incorporado ao ECHConfig é o nome de host de cobertura que os clientes colocam no
SNI externo, em texto claro. Escolha um que você controle, aponte-o para o mesmo servidor
e certifique-se de que seu certificado o cubra. Quando a tentativa de ECH de um cliente falha —
chave obsoleta, registro corrompido, middlebox — ele volta para um handshake comum
contra esse nome, e um erro de certificado lá é uma falha grave que seus usuários
verão.
Observando
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 o resultado da tentativa de ECH para a conexão.
$ssl_ech_outer_server_name fornece o nome de cobertura que o cliente usou, o que é
útil para confirmar que o cliente realmente está usando seu ECHConfig em vez de
GREASE.
Publicando o registro DNS HTTPS
O ECH não tem valor até que os clientes possam encontrar seu ECHConfigList. Ele viaja no
parâmetro ech= de um registro de recurso HTTPS:
secret.example.com. 300 IN HTTPS 1 . alpn="h3,h2" ech="AD7+DQA65wAg..."
nginx-ech-keygen --print-dns imprime o valor exato de ech="..." para a
chave atualmente anunciada.
Dois requisitos que as pessoas erram
A zona deve ser somente DNS. Se o registro estiver atrás de um CDN com proxy — um
registro Cloudflare com nuvem laranja, por exemplo — esse provedor encerra o TLS e
publica seu próprio registro HTTPS com suas próprias chaves ECH. Os clientes obtêm o
ECH do provedor, não o seu; seu ssl_ech_file nunca é exercitado, porque o
provedor é o endpoint TLS. Isso não é necessariamente ruim (o conjunto de anonimato do provedor
é enorme), mas é deles, não seu. Para servir seu próprio ECH,
o registro deve ser nuvem cinza / somente DNS.
Os clientes precisam de DNS criptografado. Um registro HTTPS obtido pela porta 53 em texto simples vaza o nome do host exatamente para o observador que o ECH pretende derrotar, então os navegadores só usam ECH quando o registro chega via DoH ou DoT. Usuários sem DNS criptografado não obtêm ECH — nada que você configure no servidor muda isso.
Exemplo com 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
Mantenha o TTL curto e confortavelmente abaixo da sua janela de retenção de chaves, para que uma
chave rotacionada nunca seja a única apontada por um registro em cache. Você só precisa
criar o registro manualmente uma vez — depois disso, nginx-ech-publish o mantém
atualizado a cada rotação (veja abaixo).
Rotação automática de chaves
As chaves ECH são feitas para ter vida curta. A ferramenta de rotação acompanha cada
build: nginx-ech para o pacote nginx padrão, nginx-mod-ech para
NGINX-MOD:
dnf install nginx-ech # nginx padrão
dnf install nginx-mod-ech # NGINX-MOD
Ele fornece nginx-ech-keygen, um timer systemd nginx-ech-rotate e
/etc/sysconfig/nginx-ech-rotate. Nada é executado até que você o configure.
# 1. Defina o nome de host de cobertura.
vi /etc/sysconfig/nginx-ech-rotate # ECH_PUBLIC_NAME="ech.example.com"
# 2. Crie a primeira chave. Grava /etc/nginx/conf.d/ech-keys.conf e recarrega.
nginx-ech-keygen --init
# 3. Publique o que ele imprime no seu registro HTTPS.
nginx-ech-keygen --print-dns
# 4. Deixe a rotação republicar o DNS por conta própria (Cloudflare incluído; veja
# "Mantenha o DNS em sincronia com a rotação" abaixo).
vi /etc/sysconfig/nginx-ech-publish # ECH_PUBLISH_PROVIDER="cloudflare"
# 5. Entregue a rotação ao timer (diariamente por padrão).
systemctl enable --now nginx-ech-rotate.timer
| Comando | Efeito |
|---|---|
--init |
Primeira chave, gera o include, recarrega o NGINX |
--rotate |
Nova chave, aposenta qualquer coisa além de ECH_RETAIN, recarrega |
--print-dns |
O valor ech="..." para a chave anunciada |
--list |
Chaves atuais, marcadas anunciada / somente descriptografia |
No EDGE
A mesma ferramenta acompanha como edge-ech, renomeada para os caminhos do próprio EDGE:
edge-ech-keygen, o timer edge-ech-rotate,
/etc/sysconfig/edge-ech-rotate e chaves em /etc/edge/ech. Cada
etapa acima se aplica com os nomes substituídos.
ECH_RETAIN (padrão 3) controla quantas gerações permanecem carregadas. Com rotação
diária, isso é aproximadamente uma janela de três dias para clientes com um registro
HTTPS em cache. Defina-o acima do TTL do seu registro, não abaixo.
Mantenha o DNS em sincronia com a rotação
A rotação sozinha deixa o DNS para trás: o ECHConfigList para o qual um cliente criptografa
vem do registro HTTPS, não do servidor, então o registro fica obsoleto na primeira
vez que o timer dispara. Nada quebra — clientes com o valor obsoleto
obtêm ECH: failed+retry-configs, ainda se conectam pelo nome de cobertura e
se autocorrigem com a retry-config. Mas nenhum primeiro handshake obtém ECH,
o que silenciosamente descarta a maior parte do benefício.
nginx-ech-publish fecha esse ciclo (incluído no nginx-mod-ech desde
1.30.4-61 e no edge-ech desde 1.30.4-6; ainda não no pacote
nginx-ech padrão). Ele é executado como o segundo ExecStart do
serviço oneshot nginx-ech-rotate.service, portanto só dispara após uma
rotação bem-sucedida: ele lê o valor anunciado de --print-dns (o
ECHConfigList público apenas, nunca a chave privada) e o republica no registro
HTTPS. Ele acompanha inerte — sem provedor configurado, ele sai sem fazer
nada. Uma edição no sysconfig o ativa:
# /etc/sysconfig/nginx-ech-publish
ECH_PUBLISH_PROVIDER="cloudflare"
ECH_ZONE_ID="..." # ID da zona na página de visão geral do domínio
ECH_RECORD_NAME="www.example.com" # o nome interno que os clientes visitam, NÃO o nome de cobertura
| Variável | Significado |
|---|---|
ECH_PUBLISH_PROVIDER |
Provider drop-in a ser executado; vazio significa não fazer nada |
ECH_ZONE_ID |
Identificador da zona do provedor |
ECH_RECORD_NAME |
FQDN do registro HTTPS — o nome interno habilitado para ECH |
ECH_ALPN |
O SvcParam alpn= anunciado junto com ech= (padrão h3,h2) |
ECH_TTL |
TTL do registro em segundos (padrão 300) |
ECH_PUBLISH_CREDENTIALS |
Arquivo de credenciais (padrão /root/.cloudflare.ini) |
O provider Cloudflare lê o formato ini do dns-cloudflare do certbot
(dns_cloudflare_email / dns_cloudflare_api_key), portanto um arquivo de credenciais
existente do certbot é reutilizado em vez de copiado. Ele atualiza o registro no local,
e uma falha na consulta do registro é uma parada brusca, nunca tratada como "sem registro" — então
ele não pode criar registros HTTPS duplicados.
Os providers são executáveis drop-in em /usr/libexec/nginx-ech-publish/.
O dispatcher exporta ECH_B64, ECH_ZONE_ID, ECH_RECORD_NAME,
ECH_ALPN, ECH_TTL e ECH_PUBLISH_CREDENTIALS para o ambiente do
provider, portanto dar suporte a Route 53, deSEC ou qualquer outra API de DNS é um
script colocado nesse diretório — sem alterações no dispatcher.
Para um provider que você mesmo scripta fora desse mecanismo, o hook
à moda antiga ainda funciona: adicione seu próprio drop-in ExecStart ao
nginx-ech-rotate.service, leia nginx-ech-keygen --print-dns e faça PUT
do valor no registro HTTPS. De qualquer forma, a rotação retém ECH_RETAIN
gerações, então a lacuna entre o reload e a propagação do DNS é coberta por
design — a chave anterior ainda está carregada para descriptografia.
Certificados: não coloque o nome interno no nome de cobertura
O certificado do nome de cobertura é apresentado no handshake externo, onde o observador que o ECH existe para derrotar pode lê-lo. Se uma única lista SAN cobrir tanto o nome de cobertura quanto os nomes que você está ocultando, esse certificado devolve exatamente o que o ECH acabou de ocultar.
Emita-os separadamente: um certificado para o nome público, um por nome interno.
O que uma janela curta demais realmente custa
Não é uma indisponibilidade, ao que parece. Medimos todos os três casos contra estes pacotes:
ECHConfigList em cache do cliente |
Resultado |
|---|---|
| A chave anunciada | ECH bem-sucedido, solicitação roteada para o nome interno |
| Uma chave retida somente descriptografia | ECH bem-sucedido, solicitação roteada para o nome interno |
Expirada além de ECH_RETAIN |
ECH: failed+retry-configs, a conexão ainda é concluída |
No terceiro caso, o NGINX não consegue descriptografar, então ele volta para o ClientHello externo, atende o bloco de servidor do nome público e retorna uma retry-config com a chave atual. O cliente a capta e se recupera por conta própria. O custo é uma ida e volta desperdiçada, não uma página quebrada.
É também por isso que o certificado do nome público importa tanto: todo cliente obsoleto cai nele. Erre esse certificado e uma rotação transforma uma tentativa recuperável em um erro de certificado.
/etc/nginx/conf.d/ech-keys.conf é gerado a cada rotação. Não o edite;
ele é reescrito para corresponder ao que está no disco. As chaves ficam em
/etc/nginx/ech, modo 0640 root:nginx, em um diretório 0750 — o master
do NGINX analisa a configuração como root, então os workers nunca precisam lê-las.
Rotacionar mais de uma vez por dia é razoável e é o que os projetistas do ECH
sugerem, mas lembre-se de que cada rotação custa um nginx -s reload. Substitua com um
drop-in em vez de editar a unit enviada:
systemctl edit nginx-ech-rotate.timer # [Timer] / OnCalendar=hourly
Suporte a clientes
Verificado contra estes pacotes, de ponta a ponta, via DoH contra um registro
HTTPS ech= real, alcançando sucesso em $ssl_ech_status:
| Cliente | Resultado |
|---|---|
| Chrome / Chromium | ECH negociado |
| Firefox 147 | ECH negociado |
openssl35 s_client -ech_config_list |
ECH negociado |
Clientes que não suportam ECH não são afetados: eles enviam um ClientHello normal e o NGINX os atende normalmente.
Você pode verificar qualquer cliente contra nosso host de demonstração público, ech-test.getpagespeed.com — ele informa o que seu navegador realmente negociou e serve os mesmos dados como JSON em /status.json.
Quando um navegador que suporta ECH não o usa
Quase sempre é DNS, não TLS. O Firefox em particular se recusa a usar ECH em várias situações em que uma configuração de teste cai facilmente, e cada uma parece uma falha de interoperabilidade sem ser nada disso. O nosso relatou GREASE por um tempo exatamente por esse motivo.
- O registro HTTPS não foi obtido via DoH. O Firefox só usa um
ECHConfigque veio de um resolvedor recursivo confiável (TRR). Ativar "DNS over HTTPS" não é suficiente por si só — um provedor precisa ser selecionado. Comnetwork.trr.modedefinido, masnetwork.trr.urivazio, o Firefox resolve nativamente e envia GREASE. - Um proxy do sistema está no caminho. O Firefox honra a configuração de proxy do SO
por padrão, incluindo um arquivo PAC. Se o DoH for roteado através desse proxy e o
proxy não o transportar, o TRR falha silenciosamente e toda consulta volta para o
DNS nativo — ou trava.
curleopenssl s_clientna mesma máquina não são afetados, o que torna isso muito convincente como um "bug do servidor". - Uma entrada em
hostscobre o nome. Um nome resolvido dessa forma nunca tem seuECHConfigobtido. - O endereço é RFC 1918. Respostas TRR contendo endereços privados são
descartadas por padrão (
network.trr.allow-rfc1918) como uma medida anti-DNS-rebinding — o que descarta o registro HTTPS que carregaech=junto com elas. - No macOS, o DoH deve estar ativado para que o ECH seja usado.
Teste contra um nome publicamente resolvível apontando para um endereço público, com um
provedor DoH explicitamente selecionado, sem proxy e sem entrada em hosts.
about:networking#dns mostra se a resposta veio do TRR.
Limitações
- O modo split não é suportado. O NGINX implementa o servidor em modo compartilhado, onde o servidor que encerra o ECH também atende o nome interno. O modo split, no qual um front end descriptografa o ECH e encaminha para um backend separado, não está no NGINX upstream.
- O ECH precisa de TLS 1.3. Não há ECH para conexões TLS 1.2.
- Um reload é necessário para que novas chaves entrem em vigor.
Veja também
- HTTP/3 (QUIC) — ECH combina naturalmente com HTTP/3
- NGINX-MOD — a build aprimorada na qual essas diretivas também estão incluídas
- RFC 9849 — TLS Encrypted Client Hello
- RFC 9460 — HTTPS and SVCB resource records