Pular para conteúdo

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 ECHConfig que veio de um resolvedor recursivo confiável (TRR). Ativar "DNS over HTTPS" não é suficiente por si só — um provedor precisa ser selecionado. Com network.trr.mode definido, mas network.trr.uri vazio, 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. curl e openssl s_client na mesma máquina não são afetados, o que torna isso muito convincente como um "bug do servidor".
  • Uma entrada em hosts cobre o nome. Um nome resolvido dessa forma nunca tem seu ECHConfig obtido.
  • 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 carrega ech= 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