1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 1 из 6

Рукопожатие: что происходит до первого запроса

Коротко

Прежде чем уйдёт хоть байт запроса, стороны договариваются о шифровании. Договор занимает круг по сети - путь пакета туда и обратно (в старой версии два) и определяет всё дальнейшее: какой сертификат покажут, по какому протоколу пойдут дальше, можно ли переиспользовать результат.

  • Первым говорит клиент: ClientHello с именем сайта открытым текстом - это и есть SNI.
  • По этому имени nginx выбирает server-блок и его сертификат, ещё до всякого HTTP.
  • В TLS 1.3 сертификат уходит зашифрованным, в 1.2 - открытым.
  • TLS 1.3 укладывается в один круг, 1.2 требует двух: замер на стенде даёт 163 против 210 миллисекунд.
  • Заодно в рукопожатии выбирается протокол поверх TLS - так согласуется HTTP/2.

Если ClientHello, SNI и разница кругов у 1.2 и 1.3 знакомы - листай до «Теперь сам».

Сначала ответь сам

На одном адресе живут три сайта с тремя разными сертификатами. Клиент открывает https://shop.local. Соединение зашифровано - значит nginx не может прочитать заголовок Host, пока не расшифрует запрос. А расшифровать он может, только предъявив сертификат.

Как он выбирает, чей сертификат показывать?

По имени, которое клиент называет до шифрования. В самом первом сообщении ClientHello есть поле SNI, и в нём открытым текстом написано shop.local. Курица и яйцо разошлись: имя сайта передаётся дважды - сначала в открытую для выбора сертификата, потом внутри шифрованного запроса как Host.

Из этого растёт всё поведение из раздела про выбор сервера: и ssl_reject_handshake для чужих имён, и правило «сертификат выбирается раньше, чем Host».

На что это похоже

Разговор через переводчика, которого ещё надо выбрать.

Ты подходишь к стойке и вслух, на понятном всем языке, говоришь: мне нужен испанский. Эту фразу слышат все вокруг - иначе тебя некому было бы направить. Дальше выходит переводчик, показывает удостоверение, уводит тебя в кабинку, и уже там начинается разговор по делу, которого не слышит никто.

Имя языка - это SNI: его нельзя сказать шёпотом, иначе не поймут, кого звать. Удостоверение - сертификат. Кабинка - шифрованный канал. А сам разговор - HTTP, который до этого момента ещё не начинался.

Механизм: два сценария рукопожатия

TLS 1.3 - один круг клиент → ClientHello (имя сайта, версии, шифры) ← сервер ServerHello, EncryptedExtensions, Certificate, Finished клиент → Finished, а следом сразу запрос TLS 1.2 - два круга клиент → ClientHello ← сервер ServerHello, Certificate, ServerKeyExchange, ServerHelloDone клиент → ClientKeyExchange, ChangeCipherSpec, Finished ← сервер ChangeCipherSpec, Finished - и только теперь запрос
Последовательность снята с живого стенда командой openssl s_client -msg. В 1.3 сертификат идёт уже внутри шифрованной части, в 1.2 - открытым текстом.

Правило

Сертификат выбирается по SNI, а не по Host. К моменту, когда nginx увидит Host, сертификат уже предъявлен и канал уже зашифрован.

Разбор: что реально летит по проводу

Живой обмен с nginx 1.31.5, версия TLS 1.3:

↳  выводopenssl s_client -connect lab.local:443 -servername lab.local -msg
>>> Handshake, ClientHello
<<< Handshake, ServerHello
<<< ChangeCipherSpec
<<< Handshake, EncryptedExtensions
<<< Handshake, Certificate

Флаг -msg велит openssl печатать сами сообщения рукопожатия; >>> - то, что отправил клиент, <<< - то, что пришло от сервера. Сообщений от сервера четыре, но идут они одной пачкой, в ответ на одно сообщение клиента - вот почему круг всего один. И Certificate стоит после EncryptedExtensions, то есть уже внутри шифрованной части: подслушивающий увидит, что кто-то соединился с этим адресом, но не увидит, чей сертификат ему показали.

Тот же стенд, версия принудительно понижена до 1.2:

↳  выводтот же обмен на listen 8443 ssl с ssl_protocols TLSv1.2
>>> Handshake, ClientHello
<<< Handshake, ServerHello
<<< Handshake, Certificate          ← открытым текстом
<<< Handshake, ServerKeyExchange
<<< Handshake, ServerHelloDone
>>> Handshake, ClientKeyExchange
>>> ChangeCipherSpec

Здесь клиент вынужден заговорить второй раз до того, как канал заработает, - это и есть второй круг. Плюс сертификат виден любому, кто смотрит на трафик.

Разница в миллисекундах, замеренная на стенде с задержкой 50 мс в одну сторону:

Что Готов к отправке запроса Ответ получен
HTTP без шифрования 51 мс 102 мс
HTTPS, TLS 1.3 112 мс 163 мс
HTTPS, TLS 1.2 160 мс 210 мс

Задержка на стенде - 50 мс в одну сторону, то есть круг туда-обратно 100 мс. «Готов к отправке» у HTTP - 51 мс: один круг на TCP-рукопожатие и сразу запрос. У TLS 1.3 добавляется круг на шифрование (112), у 1.2 - два (160). Сорок семь миллисекунд разницы между версиями - это ровно один лишний круг.

Разбор: где наивное правило подводит

SNI виден всем. Шифруется содержимое, но не имя сайта: оно обязано быть открытым, иначе сервер не выберет сертификат. Провайдер, корпоративный шлюз и любой, кто видит трафик, знает, к какому домену ты подключился, - хотя и не знает, что именно ты там запросил. Технология, скрывающая и его, существует (Encrypted Client Hello), но требует поддержки с обеих сторон и в стандартную сборку nginx пока не входит.

Старый клиент без SNI не исчез. Если имя не названо, nginx покажет сертификат блока по умолчанию для этой пары «адрес и порт». Для одного сайта это незаметно, для десяти - ошибка сертификата у того, кто пришёл на девять остальных.

Рукопожатие - это ещё и выбор протокола. В том же ClientHello клиент перечисляет, на чём готов говорить дальше: h2, http/1.1. Сервер выбирает один. Поэтому HTTP/2 не требует отдельного порта и не «включается» отдельным запросом - он согласуется здесь, за один круг вместе с шифрованием. Подробности - в разделе про HTTP/2.

Не всякое соединение платит за рукопожатие. Клиент, который уже говорил с этим сервером, может предъявить билет прошлой сессии (выданный сервером при прошлом рукопожатии ярлык «мы уже договаривались») и пропустить часть обмена, а установленное соединение переиспользуется по keep-alive - и тогда рукопожатия нет вовсе. Полную цену платит только первое соединение.

Что ломается без этого

Не зная порядка, легко искать причину не там. Ошибка сертификата у клиента - это не про HTTP: до HTTP дело не дошло, и в access_log такой запрос не появится вовсе. Строку надо искать в error_log, а сам запрос - у клиента.

Вторая беда - удивление от медленного первого захода. Страница «открывается секунду, а потом всё летает» - это три круга на первое соединение и ноль на последующие.

Зачем это в работе

Посмотреть, чем закончилось рукопожатие, можно одной командой:

$  команда
openssl s_client -connect shop.local:443 -servername shop.local </dev/null 2>/dev/null | grep -E 'Protocol|Cipher|Verify return'

Три строки отвечают на три вопроса: какая версия согласована, каким шифром идёт канал и сошлась ли цепочка доверия. Флаг -servername обязателен - без него openssl не пошлёт SNI, и ты увидишь сертификат чужого блока, а не своего.

Теперь сам

Мониторинг ходит на https://shop.local/healthz каждые пять секунд и жалуется на медленные ответы: 180 миллисекунд при бэкенде, отвечающем за десять. В логе nginx $request_time около 0,01. Где эти 170 миллисекунд?

В рукопожатии, и в лог nginx они не попадают: $request_time считается с первого байта запроса, а рукопожатие происходит раньше. Мониторинг открывает новое соединение на каждую проверку и каждый раз платит за TCP и TLS. Лечится на стороне мониторинга - переиспользовать соединение; а если проверок много, заодно стоит посмотреть на версию TLS: понижение до 1.2 добавляет к каждой проверке лишний круг.

Главное

До HTTP идёт рукопожатие: клиент шлёт ClientHello с именем сайта открытым текстом (SNI), по нему nginx выбирает server-блок и сертификат. TLS 1.3 укладывается в один круг и отдаёт сертификат уже зашифрованным; 1.2 требует двух кругов и показывает сертификат открыто - на стенде это 163 против 210 мс. В том же обмене согласуется протокол поверх TLS, поэтому HTTP/2 не нужен отдельный порт. Ошибка сертификата в access_log не попадает: до HTTP дело не дошло.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.