TLS-рукопожатие по шагам: ClientHello, сертификат, ключи — и на каком шаге оно ломается
Каждое HTTPS-соединение начинается с обмена сообщениями длиной в один-два оборота сети. Когда он срывается, браузер показывает ERR_SSL_PROTOCOL_ERROR или ERR_CERT_AUTHORITY_INVALID, curl — код 35 или 60, и по коду не видно, где сломалось. Разбираем рукопожатие TLS 1.2 и 1.3 по RFC 5246 и RFC 8446, привязываем ошибки к шагам и смотрим живой обмен через openssl и curl.
TLS-рукопожатие (TLS handshake) — процедура в начале защищённого соединения, в ходе которой клиент и сервер договариваются о версии протокола и наборе шифров, сервер подтверждает подлинность сертификатом, и обе стороны вычисляют общие сеансовые ключи. В TLS 1.2 полное рукопожатие занимает два оборота сети (2 RTT), в TLS 1.3 — один (1 RTT), а при возобновлении сессии в TLS 1.3 первый HTTP-запрос может уйти вместе с первым же сообщением (0-RTT). HTTP-данные идут только после рукопожатия: его сбой означает, что сайт не откроется, а задержка прибавляется к каждому новому соединению.
Зачем нужно рукопожатие: три задачи за один-два RTT
Рукопожатие решает три задачи: согласовать параметры (версию TLS, набор шифров, расширения SNI и ALPN), аутентифицировать сервер (клиент проверяет цепочку сертификатов и подпись его закрытым ключом) и выработать общий секрет для симметричных ключей. Без аутентификации ключи можно выработать с подставным сервером, без обмена ключами нечем шифровать.
Что согласуется, видно по имени набора шифров. В TLS 1.2 имя описывает всё разом: в TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 это обмен ключами (ECDHE), подпись в сертификате (RSA), шифр данных (AES-128-GCM) и хеш (SHA-256); этот набор RFC 9113 требует от любого сервера HTTP/2. В TLS 1.3 имя короче — TLS_AES_128_GCM_SHA256: обмен ключами и подпись вынесены в расширения supported_groups и signature_algorithms.
Как проходит рукопожатие TLS 1.2: четыре полёта и два RTT
Полное рукопожатие TLS 1.2 по RFC 5246 состоит из четырёх «полётов»: клиент шлёт ClientHello, сервер отвечает пачкой ServerHello → Certificate → ServerKeyExchange → ServerHelloDone, клиент — ClientKeyExchange → ChangeCipherSpec → Finished, сервер закрывает обмен своими ChangeCipherSpec → Finished. Первый байт HTTP-запроса уходит только на третьем обороте.
TLS 1.2 — полное рукопожатие (RFC 5246), 2 RTT
Клиент → Сервер ClientHello: версия, client_random, список наборов шифров, SNI, ALPN
▼
Сервер → Клиент ServerHello (версия, шифр, server_random) → Certificate → ServerKeyExchange (эфемерный ключ + подпись) → ServerHelloDone
конец 1-го RTT
▼
Клиент → Сервер проверяет цепочку и подпись → ClientKeyExchange → ChangeCipherSpec → Finished
▼
Сервер → Клиент ChangeCipherSpec → Finished
конец 2-го RTT — дальше HTTP-запрос
У каждого сообщения есть числовой тип по RFC 5246 — именно эти номера curl печатает в скобках при флаге -v:
| Сообщение | Тип | Кто | Что внутри |
|---|---|---|---|
| ClientHello | 1 | Клиент | Версия, client_random, наборы шифров, расширения server_name, ALPN, supported_groups, signature_algorithms |
| ServerHello | 2 | Сервер | Выбранные версия и набор, server_random, session_id |
| Certificate | 11 | Сервер | Цепочка: серверный сертификат первым, затем промежуточные |
| ServerKeyExchange | 12 | Сервер | Только для (EC)DHE: эфемерный ключ и подпись над ним. При RSA-обмене не отправляется |
| ClientKeyExchange | 16 | Клиент | Эфемерный ключ клиента (ECDHE) либо premaster secret под открытым ключом сервера (RSA) |
| Finished | 20 | Оба | Хеш всех предыдущих сообщений под новым ключом |
Ключевой момент — подпись. В ECDHE-варианте сервер подписывает закрытым ключом сертификата эфемерный ключ в ServerKeyExchange: так доказывается владение сертификатом, а сеансовый ключ не зависит от долгоживущего (forward secrecy). В RSA-варианте подписи нет, и утечка ключа сервера раскрывает весь записанный трафик. Общий обзор HTTPS — в статье «Как работает HTTPS»; здесь мы идём глубже.
Что изменилось в TLS 1.3: один RTT, обязательный ECDHE, шифрование с ServerHello
TLS 1.3 (RFC 8446, август 2018) укладывает полное рукопожатие в один RTT: клиент отправляет эфемерные ключи (расширение key_share) сразу в ClientHello, а сервер одним полётом присылает ServerHello, сертификат, подпись и Finished. Всё после ServerHello зашифровано — включая сертификат, который в TLS 1.2 шёл открытым текстом.
TLS 1.3 — полное рукопожатие (RFC 8446), 1 RTT
Клиент → Сервер ClientHello + key_share (готовые ключи X25519, P-256) + supported_versions + SNI + ALPN
▼
Сервер → Клиент ServerHello + key_share, далее уже зашифровано: EncryptedExtensions → Certificate → CertificateVerify (подпись над всем рукопожатием) → Finished
конец 1-го RTT
▼
Клиент → Сервер Finished — и в том же полёте первый HTTP-запрос. Затем сервер присылает NewSessionTicket для будущих возобновлений
Что убрано (раздел 1.2 RFC 8446): статический RSA и Diffie–Hellman как обмен ключами (обмен всегда эфемерный, все наборы дают forward secrecy), сжатие, DSA, повторное рукопожатие. Осталось пять наборов шифров, все AEAD: TLS_AES_128_GCM_SHA256 (обязателен для всех реализаций), TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256 и два CCM-варианта. Наборов с CBC, RC4, 3DES и MD5 в TLS 1.3 не существует.
0-RTT — режим возобновления, при котором клиент отправляет HTTP-запрос вместе с ClientHello, шифруя его ключом из предыдущей сессии (PSK из NewSessionTicket; срок жизни билета — не больше 604 800 секунд, 7 дней). RFC 8446 предупреждает: у 0-RTT-данных нет forward secrecy и защиты от повтора, поэтому так можно принимать только идемпотентные запросы. В nginx режим (ssl_early_data) по умолчанию выключен.
Чем TLS 1.3 отличается от TLS 1.2: сводная таблица
Главные различия — число RTT, обязательная forward secrecy и шифрование сертификата. Сводка по RFC 5246, RFC 8446, caniuse и документации вендоров на сентябрь 2026:
| Параметр | TLS 1.2 (RFC 5246, 2008) | TLS 1.3 (RFC 8446, 2018) |
|---|---|---|
| Полное рукопожатие | 2 RTT | 1 RTT (2 при HelloRetryRequest) |
| Возобновление | 1 RTT (session ID или ticket) | 1 RTT по PSK; 0-RTT для первого запроса |
| Обмен ключами | RSA, DH, DHE, ECDHE на выбор | Только (EC)DHE или PSK+(EC)DHE |
| Forward secrecy | Только с (EC)DHE-наборами | Всегда (кроме 0-RTT-данных) |
| Наборы шифров | Десятки, включая CBC, 3DES, RC4; обязательный — TLS_RSA_WITH_AES_128_CBC_SHA | 5, все AEAD; обязательный — TLS_AES_128_GCM_SHA256 |
| Сертификат сервера | Открытым текстом | Зашифрован |
| Поддержка клиентов | Повсеместно. TLS 1.0/1.1 отключены в Chrome 84 (14 июля 2020) и Firefox 78 (30 июня 2020), запрещены RFC 8996 (март 2021) | Chrome 70 (16 октября 2018), Firefox 63, Edge 79, iOS 12.2, Safari 14 (macOS), Android 10 (без 0-RTT), Windows 11 и Server 2022, OpenSSL 1.1.1 (11 сентября 2018) |
Сервер только с TLS 1.0/1.1 не откроет ни один актуальный браузер; в Chromium даже убрали код -218 ERR_SSL_OBSOLETE_VERSION — по комментарию в net_error_list.h, старые версии теперь дают ERR_SSL_VERSION_OR_CIPHER_MISMATCH.
Что внутри сертификата и цепочки: leaf → intermediate → root, SNI и ALPN
В сообщении Certificate сервер присылает цепочку: серверный сертификат (leaf) первым, за ним промежуточные (intermediate), каждый подписан ключом следующего. Корневой (root) обычно не отправляется — он лежит в хранилище доверия клиента, и к нему клиент строит путь: подпись leaf проверяется ключом intermediate, подпись intermediate — ключом root. Нет промежуточного звена — путь не строится, и OpenSSL сообщает «unable to get local issuer certificate» (коды 20/21) при валидном сертификате сайта. Документация nginx требует класть промежуточные сертификаты в тот же файл после основного.
В leaf проверяются subjectAltName (совпадение с запрошенным именем), период notBefore/notAfter (по часам клиента), keyUsage, подпись и статус отзыва. Максимальный срок действия публичных сертификатов на сентябрь 2026 года — 398 дней; по решению CA/Browser Forum от апреля 2025 года он сокращается до 47 дней к марту 2029-го. Как читать сертификат руками — в статье «Как проверить SSL-сертификат сайта».
Два расширения ClientHello решают, какой сертификат и протокол выберет сервер. SNI (server_name, RFC 6066) — имя хоста открытым текстом, по которому сервер с несколькими сайтами на одном IP выбирает сертификат; без SNI он отдаст сертификат по умолчанию. ALPN (RFC 7301) — список прикладных протоколов («h2», «http/1.1»), из которого сервер выбирает один в ServerHello, так HTTP/2 согласуется без лишнего RTT; нет общего протокола — алерт no_application_protocol (120).
На каком шаге ломается рукопожатие: ошибка → шаг → причина → что чинить
Каждая ошибка TLS привязана к шагу: до ServerHello ломаются версии и шифры, на Certificate — цепочка, срок и имя, на Finished — целостность обмена. Коды Chromium — из net_error_list.h, алертов — из RFC 8446 и RFC 6066:
| Что видно | Шаг | Причина | Что чинить |
|---|---|---|---|
| ERR_SSL_VERSION_OR_CIPHER_MISMATCH (-113); алерт handshake_failure (40) или protocol_version (70); curl 35 | ClientHello → ServerHello | Нет общей версии или набора: сервер только TLS 1.0/1.1 или только RSA/3DES; сертификат ECDSA при RSA-наборах у клиента | ssl_protocols TLSv1.2 TLSv1.3; наборы с ECDHE и AEAD |
| ERR_SSL_PROTOCOL_ERROR (-107); OpenSSL «wrong version number»; curl 35 | Любой: пришло не то, что ожидалось | На 443 отвечает не TLS (HTTP открытым текстом, чужой сервис) либо прокси или DPI вмешался в поток | Проверить порт через openssl s_client; исключить middlebox |
| ERR_CERT_AUTHORITY_INVALID (-202); OpenSSL verify 18/19/20/21; curl 60 | Certificate — построение цепочки | Самоподписанный сертификат, неизвестный корень, не отправлен промежуточный сертификат | Добавить intermediate после leaf; выпустить сертификат у публичного CA |
| ERR_CERT_DATE_INVALID (-201); алерт certificate_expired (45); OpenSSL verify 10; curl 60 | Certificate — срок | Сертификат истёк или ещё не действует; сбиты часы клиента («Our clock is wrong» в комментарии Chromium) | Перевыпустить, настроить автопродление; на клиенте — время |
| ERR_CERT_COMMON_NAME_INVALID (-200); OpenSSL verify 62; curl 60 | Certificate — имя | В SAN нет запрошенного имени: сайт по IP, забытый поддомен, сертификат по умолчанию из-за отсутствия SNI | Перевыпустить с нужными SAN; проверить, что клиент шлёт SNI |
| ERR_SSL_UNRECOGNIZED_NAME_ALERT (-159); алерт unrecognized_name (112) | ClientHello → ServerHello (SNI) | Сервер отвергает неизвестные имена в SNI (RFC 6066 допускает fatal-алерт вместо сертификата по умолчанию) | Добавить server_name в конфигурацию; проверить default_server |
| ERR_CONNECTION_CLOSED (-100) или ERR_CONNECTION_RESET (-101) во время TLS; curl 35 «Connection reset by peer» | Любой шаг до Finished, без алерта | Сервер или узел по пути молча закрыл (FIN) или сбросил (RST) сокет: DPI по SNI, антивирус, упавший воркер | curl --trace-time из разных сетей; tcpdump на сервере |
Сертификатные ошибки (-200…-207) возникают, когда сервер уже прислал всё, что должен, — проблема в данных. Ошибки -107 и -113 означают, что до сертификата не дошло. Симптомы самого частого кода — в статье про ERR_SSL_VERSION_OR_CIPHER_MISMATCH.
Как проверить TLS-рукопожатие вручную: openssl, curl, nmap
Три инструмента закрывают диагностику: openssl s_client, curl -v и nmap ssl-enum-ciphers. Порядок действий:
- Подключитесь через openssl s_client с -servername (SNI) и посмотрите Certificate chain, строку «New, TLSv1.3, Cipher is …» и «Verify return code»: 0 — порядок, 10 — истёк, 18/19 — самоподписанный, 20/21 — нет промежуточного сертификата.
- Повторите с -tls1_2, затем с -tls1_3. Если один из вариантов даёт «alert handshake failure» или «alert protocol version», проблема в ssl_protocols или наборах шифров, а не в сертификате.
- Запустите curl -v и найдите, после какого сообщения обмен прервался: после Client hello (1) — версия, шифры или DPI; после Certificate (11) — цепочка или имя; после Finished (20) — прикладной уровень.
- Просканируйте сервер nmap --script ssl-enum-ciphers -p 443: скрипт перебирает версии и наборы и ставит оценки от A до F. Всё ниже TLS 1.2 и оценки C и ниже — кандидаты на отключение.
$ openssl s_client -connect example.com:443 -servername example.com \
-tls1_3 -alpn h2 </dev/null
# пример вывода (сокращён): цепочка leaf -> intermediate -> root
Certificate chain
0 s:CN = example.com
i:C = US, O = SSL Corporation, CN = Cloudflare TLS Issuing ECC CA 3
v:NotBefore: Jul 29 22:10:08 2026 GMT; NotAfter: Oct 27 22:17:21 2026
1 s:C = US, O = SSL Corporation, CN = Cloudflare TLS Issuing ECC CA 3
i:C = US, O = SSL Corporation, CN = SSL.com TLS Transit ECC CA R2
2 s:C = US, O = SSL Corporation, CN = SSL.com TLS Transit ECC CA R2
i:C = US, O = SSL Corporation, CN = SSL.com TLS ECC Root CA 2022
---
Server Temp Key: X25519, 253 bits
SSL handshake has read 4030 bytes and written 400 bytes
Verification: OK
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
ALPN protocol: h2
Verify return code: 0 (ok)
«Server Temp Key: X25519» — группа эфемерного обмена (тот самый key_share), «4030 bytes» — размер полёта сервера, в основном цепочка. На истёкшем сертификате здесь будет «Verify return code: 10 (certificate has expired)», без промежуточного — «21 (unable to verify the first certificate)», при отсутствии общего набора шифров — «alert handshake failure … SSL alert number 40» и «no peer certificate available».
$ curl -v --tls-max 1.2 https://example.com/ -o /dev/null
# пример вывода: TLS 1.2, в скобках - тип сообщения по RFC 5246
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-ECDSA-CHACHA20-POLY1305 / X25519
$ curl -v --tlsv1.3 https://badssl.com/ -o /dev/null
# пример вывода: сервер без TLS 1.3 - обрыв сразу после Client hello
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS alert, handshake failure (552):
curl: (35) OpenSSL/3.0.13: error:0A000410:SSL routines::
sslv3 alert handshake failure
Без --tls-max тот же сервер ответит по TLS 1.3: после Server hello (2) одним полётом пойдут Encrypted Extensions (8), Certificate (11), CERT verify (15) и Finished (20), а после рукопожатия — Newsession Ticket (4). Коды выхода curl делят ошибки на две группы: 35 — «проблема где-то в SSL/TLS-рукопожатии» (версии, шифры, обрыв), 60 — «сертификат сервера признан негодным» (цепочка, срок, имя). «Server finished (14)» — это ServerHelloDone, не путать с Finished (20).
$ nmap -sV --script ssl-enum-ciphers -p 443 example.com # пример вывода из документации скрипта (сокращён) | ssl-enum-ciphers: | TLSv1.0: | ciphers: | TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA (secp256r1) - A | TLS_RSA_WITH_3DES_EDE_CBC_SHA (rsa 2048) - C | warnings: | 64-bit block cipher 3DES vulnerable to SWEET32 attack | TLSv1.2: | ciphers: | TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (secp256r1) - A |_ least strength: C
Отчёт — готовый список на отключение: секция TLSv1.0 целиком, 3DES с SWEET32, наборы TLS_RSA_* без forward secrecy. Оценки, по документации nmap, следуют шкале Qualys SSL Labs.
Сколько стоит рукопожатие в миллисекундах и как его ускорить
Цена рукопожатия измеряется в RTT: полный TLS 1.2 добавляет к каждому новому соединению два оборота сети, TLS 1.3 — один, и это поверх одного RTT на TCP. При условном RTT 50 мс первый байт ответа по TLS 1.2 приходит не раньше 200 мс плюс время сервера, по TLS 1.3 — 150 мс, при 0-RTT — 100 мс.
| Фаза | RTT | При RTT 50 мс | Переменная curl -w |
|---|---|---|---|
| DNS | 0 из кеша, иначе 1+ | 0–50 мс | time_namelookup |
| TCP | 1 | 50 мс | time_connect − time_namelookup |
| TLS 1.2, полное | 2 | 100 мс | time_appconnect − time_connect |
| TLS 1.3, полное или возобновление | 1 (0 для данных в 0-RTT) | 50 мс | time_appconnect − time_connect |
| Запрос → первый байт (TTFB) | 1 + сервер | 50 мс + сервер | time_starttransfer − time_appconnect |
Разложить соединение на фазы умеет сам curl: переменные -w отсчитываются от старта, длительность фазы — разность соседних значений:
$ cat > fmt.txt <<'EOF'
dns=%{time_namelookup}
tcp=%{time_connect}
tls=%{time_appconnect}
ttfb=%{time_starttransfer}
total=%{time_total}
EOF
$ curl -s -o /dev/null -w @fmt.txt https://example.com/
# пример вывода (секунды, накопительно от старта)
dns=0.012
tcp=0.061 # TCP занял 0.049 с - один RTT ~49 мс
tls=0.114 # TLS занял 0.053 с - один RTT, значит TLS 1.3
ttfb=0.171 # сервер ответил через 0.057 с после рукопожатия
total=0.173
# с --tls-max 1.2 фаза TLS удвоится: два RTT вместо одного
Если фаза TLS заметно больше одного-двух RTT, причины обычно четыре: тяжёлая цепочка (RSA-4096 с несколькими промежуточными звеньями может не уместиться в начальное окно TCP; ECDSA и короткие цепочки решают это), поход клиента за OCSP-ответом посреди рукопожатия (лечится OCSP stapling; роль OCSP снижается — Let's Encrypt отключил свои ответчики 6 августа 2025 года и перешёл на CRL), отсутствие возобновления сессий и HelloRetryRequest из-за несовпадения кривых у клиента и сервера.
# nginx: минимальный современный блок TLS
server {
listen 443 ssl;
http2 on;
# leaf первым, затем промежуточные
ssl_certificate /etc/ssl/example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/example.com/privkey.pem;
# умолчание с nginx 1.23.4; задаём явно
ssl_protocols TLSv1.2 TLSv1.3;
# TLS 1.2: только ECDHE + AEAD (умолчание - HIGH:!aNULL:!MD5);
# наборы TLS 1.3 этой директивой не управляются
ssl_ciphers ECDHE+AESGCM:ECDHE+CHACHA20;
# возобновление: ~4000 сессий на 1 МБ (умолчание таймаута 5m)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets on;
ssl_stapling on; # OCSP stapling, умолчание off
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/example.com/chain.pem;
# ssl_early_data on; # 0-RTT: по умолчанию off
}
Самый большой выигрыш даёт отсутствие рукопожатий: HTTP/2 держит одно соединение для всех запросов к хосту, keep-alive в HTTP/1.1 переиспользует соединение, возобновление сессий срезает RTT тем, кто подключается заново.
Разовая проверка через openssl показывает состояние на одну минуту с одной машины; за рукопожатием нужно следить постоянно и из разных сетей. HTTP-проверка в Пруфене раскладывает время ответа на фазы — DNS, TCP, TLS, сервер, загрузка — и показывает TLS отдельной цифрой по каждой из 20 точек мониторинга в России, Беларуси, Казахстане и Испании с интервалом от 2 минут: рост фазы TLS из одного региона при здоровом сервере — признак вмешательства в трафик по пути. SSL-проверка следит за сроком сертификата и цепочкой и предупреждает в Email, Telegram или Макс; если ресурс так и не поднялся, подключается голосовой звонок.
Частые вопросы
Почему ошибка называется «SSL handshake failed», если SSL давно нет? По инерции: SSL 2.0 и 3.0 были предшественниками TLS в 1995–1996 годах, TLS 1.0 вышел в 1999-м, и библиотеки сохранили старые имена — отсюда «SSL routines» и «sslv3 alert» в OpenSSL даже для TLS 1.3. Под «SSL handshake failed» понимают любой срыв рукопожатия, чаще всего — алерт handshake_failure (40) из-за отсутствия общего набора шифров или версии.
Сколько RTT занимает TLS-рукопожатие вместе с TCP? До первого байта HTTP-ответа: 1 RTT на TCP плюс 2 RTT на TLS 1.2 или 1 RTT на TLS 1.3, плюс 1 RTT на сам запрос — итого 4 или 3 RTT. При возобновлении TLS 1.3 с 0-RTT остаётся 2 RTT. В HTTP/3 поверх QUIC рукопожатие TLS 1.3 встроено в установку соединения, отдельного TCP-оборота нет.
Почему браузер считает сертификат недействительным, а openssl s_client пишет «Verify return code: 0»? Чаще всего из-за имени хоста: без -verify_hostname s_client проверяет только цепочку и срок. Вторая причина — разные хранилища доверия: openssl берёт системные корни, браузер — свои. Третья — дополнительные проверки браузера: Certificate Transparency, длина срока действия, известные перехватывающие сертификаты (в Chromium — коды -207, -217).