Encrypted Client Hello (ECH)
Encrypted Client Hello (RFC 9849) закрывает последнюю крупную утечку открытого текста в рукопожатии TLS: Server Name Indication. Без него каждое соединение TLS 1.3 объявляет имя хоста, которое вы посещаете, в открытом виде, где его может прочитать любой наблюдатель на пути. ECH оборачивает реальный ClientHello — имя хоста и всё остальное — во внешний, который называет только общее, публичное «прикрывающее» имя хоста.
ssl_ech_file и переменные $ssl_ech_status / $ssl_ech_outer_server_name
доступны в стандартном пакете nginx, nginx-mod и edge —
все сборки GetPageSpeed линкуются с openssl35 (OpenSSL 3.5 LTS с
бэкпортом ECH). Никакого патчинга NGINX не требуется; директивы поступают из
самого NGINX и включаются, когда библиотека TLS поддерживает ECH.
Попробуйте, прежде чем настраивать
ech-test.getpagespeed.com запускает
именно этот стек на публичном порту 443, с ключами, ротируемыми ежедневно тем же
пакетом nginx-mod-ech, описанным ниже. Он сообщает, что согласовал ваш
собственный браузер, так что вы можете отличить проблему DNS на стороне клиента
от проблемы на стороне сервера, прежде чем трогать свою собственную конфигурацию.
Действительно ли ECH вам помогает?
Будьте честны с собой относительно модели угроз перед развёртыванием.
ECH скрывает какое имя вы запросили среди имён, разделяющих сервер. Он не скрывает что вы подключились, и не скрывает IP-адрес.
- Мультитенантный хостинг, CDN, общие фронтенды — реальный, существенный выигрыш. Наблюдатель узнаёт, что вы достигли сервера, размещающего тысячи сайтов, и ничего больше.
- Один сайт на выделенном IP — очень небольшой выигрыш. Адрес сам по себе идентифицирует сайт, а обратный DNS или запрос к Certificate Transparency завершает работу. ECH по-прежнему удаляет строку SNI, что стоит чего-то против грубой цензуры по сопоставлению ключевых слов, но не переоценивайте это.
Конфиденциальность ECH исходит из размера анонимного множества за публичным именем. Прикрывающее имя, используемое одним сайтом, ничего не защищает.
Конфигурация
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 инертен, пока не присутствует ssl_ech_file, поэтому добавление пакета
ничего не меняет в существующем развёртывании.
ssl_ech_file допустим как в контексте http, так и server. Размещение его на
уровне http применяет его к каждому TLS-серверу, который его наследует, что обычно
и есть то, что вам нужно — и это безвредно для обычных HTTP-серверов, потому что NGINX
читает ключи ECH только для серверов, у которых есть сертификаты.
Несколько ключей и почему порядок важен
ssl_ech_file может повторяться. Порядок не косметический:
ssl_ech_file /etc/nginx/ech/ech-20260825T000000Z.pem; # анонсируется
ssl_ech_file /etc/nginx/ech/ech-20260824T000000Z.pem; # только расшифровка
ssl_ech_file /etc/nginx/ech/ech-20260823T000000Z.pem; # только расшифровка
NGINX анонсирует только первый файл в retry-configs ECH. Каждый последующий файл
загружается только для расшифровки. Именно это делает ротацию ключей безопасной: клиент,
который получил более старый ECHConfigList из кэшированной HTTPS-записи, зашифровал
старым ключом, и этот ключ всё ещё должен быть в хранилище, иначе рукопожатие завершится
ошибкой.
Ключи читаются, когда NGINX разбирает свою конфигурацию, поэтому вновь
сгенерированный ключ ничего не делает до nginx -s reload.
Публичное имя нуждается в сертификате
public_name, встроенный в ECHConfig, — это прикрывающее имя хоста, которое клиенты
помещают во внешний, незашифрованный SNI. Выберите то, которым вы управляете, укажите
его на тот же сервер и убедитесь, что ваш сертификат его покрывает. Когда попытка ECH
клиента не удаётся — устаревший ключ, повреждённая запись, middlebox — он откатывается
к обычному рукопожатию с этим именем, и ошибка сертификата там — это жёсткий сбой,
который увидят ваши пользователи.
Наблюдение за этим
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 сообщает результат попытки ECH для соединения.
$ssl_ech_outer_server_name даёт прикрывающее имя, которое использовал клиент, что
полезно для подтверждения того, что клиент действительно использует ваш ECHConfig,
а не GREASE.
Публикация HTTPS-записи DNS
ECH бесполезен, пока клиенты не могут найти ваш ECHConfigList. Он передаётся в
параметре ech= ресурсной записи HTTPS:
secret.example.com. 300 IN HTTPS 1 . alpn="h3,h2" ech="AD7+DQA65wAg..."
nginx-ech-keygen --print-dns печатает точное значение ech="..." для
текущего анонсируемого ключа.
Два требования, которые люди понимают неправильно
Зона должна быть только DNS. Если запись находится за проксирующим CDN —
например, запись Cloudflare с оранжевым облаком — этот провайдер завершает TLS и
публикует свою собственную HTTPS-запись со своими собственными ключами ECH. Клиенты
получают ECH провайдера, а не ваш; ваш ssl_ech_file никогда не используется, потому
что провайдер является конечной точкой TLS. Это не обязательно плохо (анонимное
множество провайдера огромно), но это их, а не ваше. Чтобы обслуживать свой собственный
ECH, запись должна быть серой / только DNS.
Клиентам нужен зашифрованный DNS. HTTPS-запись, полученная через открытый порт 53, раскрывает имя хоста именно тому наблюдателю, для защиты от которого предназначен ECH, поэтому браузеры используют ECH только тогда, когда запись пришла через DoH или DoT. Пользователи без зашифрованного DNS не получают ECH — ничего, что вы настроите на сервере, это не изменит.
Пример 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
Держите TTL коротким и с комфортом ниже вашего окна хранения ключей, чтобы
выведенный из ротации ключ никогда не был единственным, на который указывает
кэшированная запись. Вам нужно создать запись вручную только один раз — после этого
nginx-ech-publish поддерживает её актуальной при каждой ротации (см. ниже).
Автоматическая ротация ключей
Ключи ECH предназначены для короткого срока жизни. Инструменты ротации поставляются
вместе с каждой сборкой: nginx-ech для стандартного пакета nginx, nginx-mod-ech для
NGINX-MOD:
dnf install nginx-ech # стандартный nginx
dnf install nginx-mod-ech # NGINX-MOD
Он предоставляет nginx-ech-keygen, системный таймер nginx-ech-rotate и
/etc/sysconfig/nginx-ech-rotate. Ничего не запускается, пока вы это не настроите.
# 1. Установите прикрывающее имя хоста.
vi /etc/sysconfig/nginx-ech-rotate # ECH_PUBLIC_NAME="ech.example.com"
# 2. Создайте первый ключ. Записывает /etc/nginx/conf.d/ech-keys.conf и перезагружает.
nginx-ech-keygen --init
# 3. Опубликуйте то, что он печатает, в вашей HTTPS-записи.
nginx-ech-keygen --print-dns
# 4. Позвольте ротации перепубликовывать DNS самостоятельно (Cloudflare поставляется первым; см.
# «Держите DNS в ногу с ротацией» ниже).
vi /etc/sysconfig/nginx-ech-publish # ECH_PUBLISH_PROVIDER="cloudflare"
# 5. Передайте ротацию таймеру (ежедневно по умолчанию).
systemctl enable --now nginx-ech-rotate.timer
| Команда | Эффект |
|---|---|
--init |
Первый ключ, генерирует include, перезагружает NGINX |
--rotate |
Новый ключ, выводит всё, что старше ECH_RETAIN, перезагружает |
--print-dns |
Значение ech="..." для анонсируемого ключа |
--list |
Текущие ключи, помеченные как анонсируемые / только расшифровка |
На EDGE
Тот же инструментарий поставляется как edge-ech, переименованный под
собственные пути EDGE: edge-ech-keygen, таймер edge-ech-rotate,
/etc/sysconfig/edge-ech-rotate и ключи в /etc/edge/ech. Каждый
шаг выше применим с заменой имён.
ECH_RETAIN (по умолчанию 3) контролирует, сколько поколений остаётся загруженными.
При ежедневной ротации это примерно трёхдневное льготное окно для клиентов, держащих
кэшированную HTTPS-запись. Установите его выше TTL вашей записи, а не ниже.
Держите DNS в ногу с ротацией
Одна только ротация оставляет DNS позади: ECHConfigList, к которому клиент
шифрует, поступает из HTTPS-записи, а не с сервера, поэтому запись устаревает при
первом же срабатывании таймера. Ничего не ломается — клиенты, держащие устаревшее
значение, получают ECH: failed+retry-configs, всё ещё подключаются через
прикрывающее имя и самовосстанавливаются из retry-config. Но ни одно первое
рукопожатие никогда не получает ECH, что тихо выбрасывает большую часть выгоды.
nginx-ech-publish замыкает этот цикл (поставляется в nginx-mod-ech начиная с
1.30.4-61 и edge-ech начиная с 1.30.4-6; пока нет в стандартном
пакете nginx-ech). Он запускается как второй ExecStart одноразового
сервиса nginx-ech-rotate.service, поэтому срабатывает только после успешной
ротации: он читает анонсируемое значение из --print-dns (публичный
ECHConfigList только, никогда приватный ключ) и перепубликовывает его в HTTPS-
записи. Он поставляется инертным — без настроенного провайдера он завершается, ничего
не делая. Одна правка sysconfig включает его:
# /etc/sysconfig/nginx-ech-publish
ECH_PUBLISH_PROVIDER="cloudflare"
ECH_ZONE_ID="..." # ID зоны со страницы обзора домена
ECH_RECORD_NAME="www.example.com" # внутреннее имя, которое посещают клиенты, НЕ прикрывающее имя
| Переменная | Значение |
|---|---|
ECH_PUBLISH_PROVIDER |
Подключаемый модуль провайдера для запуска; пусто — ничего не делать |
ECH_ZONE_ID |
Идентификатор зоны провайдера |
ECH_RECORD_NAME |
FQDN HTTPS-записи — внутреннее имя с поддержкой ECH |
ECH_ALPN |
SvcParam alpn=, анонсируемый вместе с ech= (по умолчанию h3,h2) |
ECH_TTL |
TTL записи в секундах (по умолчанию 300) |
ECH_PUBLISH_CREDENTIALS |
Файл учётных данных (по умолчанию /root/.cloudflare.ini) |
Провайдер Cloudflare читает формат ini dns-cloudflare от certbot
(dns_cloudflare_email / dns_cloudflare_api_key), поэтому существующий файл
учётных данных certbot переиспользуется, а не копируется. Он обновляет запись на месте,
и неудачный поиск записи является жёсткой остановкой, никогда не трактуется как
«нет записи» — поэтому он не может создать дублирующиеся HTTPS-записи.
Провайдеры — это подключаемые исполняемые файлы в /usr/libexec/nginx-ech-publish/.
Диспетчер экспортирует ECH_B64, ECH_ZONE_ID, ECH_RECORD_NAME,
ECH_ALPN, ECH_TTL и ECH_PUBLISH_CREDENTIALS в окружение
провайдера, поэтому поддержка Route 53, deSEC или любого другого DNS API — это один
скрипт, помещённый в этот каталог — без изменений диспетчера.
Для провайдера, которого вы скриптуете сами вне этого механизма, старомодный хук
по-прежнему работает: добавьте свой собственный drop-in ExecStart в
nginx-ech-rotate.service, прочитайте nginx-ech-keygen --print-dns и сделайте
PUT значения в HTTPS-запись. В любом случае ротация сохраняет ECH_RETAIN
поколений, поэтому разрыв между перезагрузкой и распространением DNS покрыт
конструктивно — предыдущий ключ всё ещё загружен для расшифровки.
Сертификаты: не помещайте внутреннее имя на прикрывающее имя
Сертификат прикрывающего имени предъявляется во внешнем рукопожатии, где наблюдатель, для защиты от которого существует ECH, может его прочитать. Если один список SAN покрывает и прикрывающее имя, и имена, которые вы скрываете, этот сертификат возвращает ровно то, что ECH только что скрыл.
Выпускайте их отдельно: один сертификат для публичного имени, по одному на каждое внутреннее имя.
Что на самом деле стоит слишком короткое окно
Не простой, как выясняется. Мы измерили все три случая на этих пакетах:
Кэшированный ECHConfigList клиента |
Результат |
|---|---|
| Анонсируемый ключ | ECH успешен, запрос направлен на внутреннее имя |
| Сохранённый ключ только для расшифровки | ECH успешен, запрос направлен на внутреннее имя |
Выведен из ECH_RETAIN |
ECH: failed+retry-configs, соединение всё равно завершается |
В третьем случае NGINX не может расшифровать, поэтому он откатывается к внешнему ClientHello, обслуживает серверный блок публичного имени и возвращает retry-config с текущим ключом. Клиент подхватывает его и восстанавливается самостоятельно. Стоимость — потраченный впустую дополнительный обход, а не сломанная страница.
Это также причина, почему сертификат публичного имени так важен: каждый устаревший клиент приземляется на него. Ошибитесь с этим сертификатом, и ротация превратит восстановимый retry в ошибку сертификата.
/etc/nginx/conf.d/ech-keys.conf генерируется при каждой ротации. Не редактируйте
его; он перезаписывается в соответствии с тем, что на диске. Ключи находятся в
/etc/nginx/ech, режим 0640 root:nginx, в каталоге 0750 — мастер NGINX
разбирает конфигурацию как root, поэтому воркерам никогда не нужно их читать.
Ротация чаще, чем ежедневно, разумна и именно это предлагают разработчики ECH,
но помните, что каждая ротация стоит nginx -s reload. Переопределите с помощью
drop-in, а не редактируя поставляемый unit:
systemctl edit nginx-ech-rotate.timer # [Timer] / OnCalendar=hourly
Поддержка клиентов
Проверено на этих пакетах, от начала до конца, через DoH против реальной HTTPS-
записи ech=, с достижением успеха $ssl_ech_status:
| Клиент | Результат |
|---|---|
| Chrome / Chromium | ECH согласован |
| Firefox 147 | ECH согласован |
openssl35 s_client -ech_config_list |
ECH согласован |
Клиенты, не поддерживающие ECH, не затрагиваются: они отправляют обычный ClientHello, и NGINX обслуживает их нормально.
Вы можете проверить любого клиента на нашем публичном демонстрационном хосте, ech-test.getpagespeed.com — он сообщает, что ваш браузер фактически согласовал, и отдаёт те же данные в виде JSON на /status.json.
Когда браузер, поддерживающий ECH, не использует его
Почти всегда DNS, а не TLS. Firefox в частности отказывается использовать ECH в нескольких ситуациях, в которые легко попадает тестовая настройка, и каждая выглядит как ошибка интероперабельности, не будучи таковой. Наш сообщал GREASE в течение некоторого времени именно по этой причине.
- HTTPS-запись не была получена через DoH. Firefox использует только
ECHConfig, который пришёл от доверенного рекурсивного резолвера (TRR). Включения «DNS over HTTPS» недостаточно само по себе — должен быть выбран провайдер. С установленнымnetwork.trr.mode, но пустымnetwork.trr.uri, Firefox резолвит нативно и отправляет GREASE. - Системный прокси мешает. Firefox по умолчанию использует конфигурацию
системного прокси, включая PAC-файл. Если DoH маршрутизируется через этот прокси,
а прокси не хочет его пропускать, TRR молча выходит из строя, и каждый поиск
откатывается к нативному DNS — или зависает.
curlиopenssl s_clientна той же машине не затрагиваются, что делает это очень убедительным как «ошибка сервера». - Запись в
hostsпокрывает имя. Имя, разрешённое таким образом, вообще никогда не получает свойECHConfig. - Адрес — RFC 1918. Ответы TRR, содержащие частные адреса, отбрасываются по
умолчанию (
network.trr.allow-rfc1918) как мера против DNS-rebinding — что отбрасывает и HTTPS-запись сech=вместе с ними. - На macOS DoH должен быть включён, чтобы ECH использовался вообще.
Тестируйте против публично разрешимого имени, указывающего на публичный адрес, с
явно выбранным провайдером DoH, без прокси и без записи в hosts.
about:networking#dns показывает, пришёл ли ответ от TRR.
Ограничения
- Раздельный режим не поддерживается. NGINX реализует сервер общего режима, где сервер, завершающий ECH, также обслуживает внутреннее имя. Раздельный режим, в котором фронтенд расшифровывает ECH и пересылает на отдельный бэкенд, отсутствует в апстриме NGINX.
- ECH требует TLS 1.3. ECH не существует для соединений TLS 1.2.
- Требуется перезагрузка, чтобы новые ключи вступили в силу.
См. также
- HTTP/3 (QUIC) — ECH естественно сочетается с HTTP/3
- NGINX-MOD — расширенная сборка, в которой также поставляются эти директивы
- RFC 9849 — TLS Encrypted Client Hello
- RFC 9460 — HTTPS and SVCB resource records