Компьютерные сети TLS и HTTPS: рукопожатие, сертификаты, цепочки доверия, отладка
0%

TLS и HTTPS: рукопожатие, сертификаты, цепочки доверия, отладка

TLS и HTTPS: рукопожатие, сертификаты, цепочки доверия, отладка

TCP даёт надёжный поток байтов и ровно ноль защиты. Любой, кто стоит на пути — Wi-Fi точка в кафе, провайдер, скомпрометированный маршрутизатор, DPI на границе — читает ваш HTTP целиком, меняет ответ на лету и подставляет свой RST. Это не теория: инъекция рекламы провайдерами и подмена JS-файлов в транзитных сетях были массовой практикой ровно до тех пор, пока HTTPS не стал умолчанием.

TLS решает три разные задачи, и путать их — источник половины неверных решений:

  • Конфиденциальность — посторонний не прочитает содержимое.
  • Целостность — посторонний не изменит содержимое незаметно.
  • Аутентификация сервера — вы говорите именно с bank.example, а не с тем, кто перехватил маршрут.

Первые две почти бесплатны и решаются симметричной криптографией. Третья — тяжёлая, политическая и именно она порождает всю боль с сертификатами. Аналогия, которая держится всю статью: запечатанный конверт с нотариально заверенной подписью. Шифрование — это конверт (никто не прочтёт). MAC/AEAD-тег — это пломба (никто не подменит). Сертификат — это нотариус, который подтверждает, что подпись на конверте принадлежит именно той организации. Проблема всегда в нотариусе: кто он, почему вы ему верите и что делать, когда он ошибся.

Актуальная спецификация — RFC 8446 (TLS 1.3), предыдущая — RFC 5246 (TLS 1.2). Криптографические примитивы разбираются в треке безопасности (прикладная криптография, безопасность транспорта); здесь — протокол, провод и отладка.

Что TLS даёт и чем вы за это платите

Свойство Механизм Цена
Конфиденциальность AEAD-шифр (AES-GCM, ChaCha20-Poly1305) CPU на шифрование, практически бесплатно при AES-NI
Целостность и защита от подмены тег AEAD, заголовок записи как associated data 16 байт на запись
Аутентификация сервера X.509 и цепочка до корня в хранилище доверия вся индустрия УЦ, истекающие сертификаты, ротация
Forward secrecy эфемерный ECDHE на каждое соединение одна операция на кривой, ~50 мкс
Защита от отката версии «маячки» в ServerHello.random, транскрипт в Finished сложность спецификации
Аутентификация клиента (mTLS) CertificateRequest + CertificateVerify PKI для клиентов, ротация, отзыв
Скрытие содержимого шифруется всё после ServerHello SNI на проводе виден, пока нет ECH

Три вещи, которых TLS не даёт и за которые их регулярно принимают:

  • Не даёт анонимности. Наблюдатель видит IP, порт, имя сайта в SNI, версию, шифронабор, размеры и тайминги записей. По одному этому «отпечатку трафика» уверенно определяют, какую страницу вы открыли на известном сайте.
  • Не даёт защиты от плохого сервера. «Зелёный замочек» означает «канал до этого хоста цел», а не «этот хост честный». Фишинговый сайт получает бесплатный DV-сертификат за десять секунд.
  • Не даёт авторизации. Сертификат отвечает на вопрос «кто ты», а не «что тебе можно» — это отдельная тема.

Как мы сюда пришли

Мораль этой хронологии простая: почти каждая дыра в TLS жила в согласовании и в устаревших опциях, а не в самой криптографии. Поэтому главный дизайнерский ход TLS 1.3 — не «добавить хорошего», а «удалить всё, что можно сломать». Из протокола выброшены: передача ключа через RSA (не давала forward secrecy и породила ROBOT), статический DH, все режимы CBC, RC4, сжатие, пересогласование, произвольные DH-группы, подписи MD5 и SHA-1, кастомные наборы шифров с сотней комбинаций. Осталось пять шифронаборов и три кривые.

Запись, рукопожатие и что видно на проводе

Структура TLS-записи и полёт рукопожатия

Ключевой момент, который стоит усвоить раньше остального: TLS — это не «шифрование TCP», а свой уровень с собственным фреймингом. Поверх байтового потока TCP лежат записи (records) длиной до 16 КБ + 256 байт. Границы записей не совпадают с границами TCP-сегментов: одна запись может ехать в трёх сегментах, а три мелкие записи — в одном. Именно поэтому нельзя «посмотреть один пакет и понять, что там» — нужен реассемблер, и его делает Wireshark.

В TLS 1.3 заголовок записи всегда врёт: opaque_type = 0x17 (application_data) и legacy_record_version = 0x0303 (TLS 1.2) даже во время рукопожатия. Это не криптография, это уклонение от middlebox’ов: за двадцать лет корпоративные файрволы и «оптимизаторы трафика» научились ломать всё, что выглядит непривычно. Настоящая версия лежит в расширении supported_versions, а настоящий тип сообщения спрятан внутрь шифротекста. Отсюда же и фиктивная запись ChangeCipherSpec, у которой в 1.3 нет никакого смысла, кроме «выглядеть как TLS 1.2» — это официально названо compatibility mode. Ту же болезнь и то же лечение мы видели в QUIC, только там решили радикально: спрятать вообще всё.

Рукопожатие TLS 1.3 за один RTT

Что здесь важно и неочевидно:

  • Клиент угадывает группу заранее. В ClientHello уже лежит публичная часть ключа X25519. Если сервер согласен — рукопожатие укладывается в один RTT. Если сервер хочет другую группу, он отвечает HelloRetryRequest и рукопожатие превращается в 2 RTT. Именно поэтому переключение на непопулярную кривую «ради безопасности» иногда удваивает latency.
  • CertificateVerify — сердце аутентификации. Сервер подписывает своим приватным ключом хеш всего транскрипта рукопожатия. Это доказывает не «у меня есть сертификат» (сертификат публичный, его может прислать кто угодно), а «у меня есть приватный ключ и я участвую именно в этом соединении». Подпись по транскрипту убивает подмену сообщений и переигрывание.
  • Finished закрывает согласование. HMAC по всему транскрипту от обеих сторон означает: если middlebox вырезал расширение или подменил список шифров, хеши разойдутся и соединение умрёт. Это и есть защита от downgrade.
  • Сертификат едет уже зашифрованным. В TLS 1.2 он летел открытым текстом, и пассивный наблюдатель видел, к какому виртуальному хосту вы идёте, даже без SNI. В 1.3 это закрыто.
  • Билеты приходят после. NewSessionTicket — это post-handshake сообщение, оно не входит в рукопожатие и приходит уже по установленному каналу.

Для контраста — то же самое в TLS 1.2, где до первого байта приложения два полных RTT:

Разница в один RTT кажется мелочью, пока вы не посчитаете: при RTT 120 мс между Москвой и Франкфуртом это 120 мс на каждое новое соединение, а мобильный клиент с RTT 200+ мс теряет пятую часть секунды на пустом месте. Сложите с рукопожатием TCP — и получите, почему пул соединений и HTTP/2 экономят больше, чем любой тюнинг приложения.

Ключевое расписание: откуда берутся ключи

Из общего ECDHE-секрета выводится не один ключ, а целое дерево через HKDF (RFC 5869). Порядок такой:

Early Secret      = HKDF-Extract(соль=0, PSK или нули)
   └── client_early_traffic_secret        → ключи для 0-RTT данных
Handshake Secret  = HKDF-Extract(Derive(Early), ECDHE)
   ├── client_handshake_traffic_secret    → клиент шифрует Finished
   └── server_handshake_traffic_secret    → сервер шифрует Certificate и т.д.
Master Secret     = HKDF-Extract(Derive(Handshake), нули)
   ├── client/server_application_traffic_secret_0  → рабочие ключи данных
   ├── exporter_master_secret             → ключи для сторонних протоколов
   └── resumption_master_secret           → из него делаются билеты

Три практических следствия. Первое: направления шифруются разными ключами — компрометация одного не даёт читать встречный поток. Второе: KeyUpdate позволяет прокрутить ключи прямо посреди долгого соединения без пересогласования, что важно для лимитов AEAD (для AES-GCM это порядка 2^24.5 записей на ключ). Третье: exporter_master_secret — легальный способ привязать прикладной протокол к конкретному TLS-соединению (channel binding), на нём построены токены, которые нельзя переиграть в другом канале.

Сертификаты и цепочка доверия

Цепочка доверия X.509 и проверки клиента

Сертификат X.509 — это структура ASN.1 в кодировке DER (двоичной), которую для удобства оборачивают в base64 между -----BEGIN CERTIFICATE----- и -----END CERTIFICATE----- — это формат PEM. Один PEM-файл может содержать несколько сертификатов подряд, и именно так устроен fullchain.pem.

Значимые поля:

Поле Что значит Типичная ошибка
Serial Number уникальный в рамках УЦ идентификатор им ищут сертификат в CRL и логах CT
Issuer Subject того, кто подписал не совпал с Subject следующего звена → цепочка не строится
Validity notBefore … notAfter часы клиента ушли → «сертификат ещё не действителен»
Subject «кто это» CN здесь не проверяется как имя хоста уже лет десять
subjectAltName список DNS-имён и IP забыли добавить www → ошибка только на одном из двух адресов
basicConstraints CA:TRUE/FALSE, pathlen без CA:TRUE промежуточным быть нельзя
keyUsage / extendedKeyUsage для чего годится ключ нет serverAuth → сертификат не примут для HTTPS
authorityInfoAccess URL промежуточного и OCSP по нему браузер «дотягивает» пропущенное звено
SCT-список доказательства публикации в CT без них Chrome отвергнет сертификат

Ключевое, что нужно понять про доверие: валидность не свойство сертификата. Она существует только в тройке «сертификат + хранилище доверия + момент времени + имя хоста». Один и тот же сертификат валиден в браузере и невалиден в alpine-контейнере без ca-certificates; валиден сегодня и невалиден завтра. Отсюда почти все «магические» инциденты.

Алгоритм проверки, который выполняет клиент

Обратите внимание на два узла, где на практике теряют больше всего времени.

Построение пути — не «идти по Issuer вверх», а поиск. У одного и того же ключа УЦ может быть несколько сертификатов: собственный корневой и кросс-подписанный чужим корнем ради совместимости со старыми устройствами. Классический случай — истечение DST Root CA X3 30 сентября 2021 года. Let’s Encrypt слал цепочку с кросс-подписью, современные клиенты умели её «обрезать» и дойти до собственного корня ISRG Root X1, а OpenSSL 1.0.2 и старый Android упирались в просроченный корень и падали. Сертификат сервера был идеален, ошибка была в алгоритме построения пути у клиента. С тех пор OpenSSL 1.1.0+ умеет перебирать альтернативные пути.

Отзыв работает плохо, и это надо принять. CRL — это файл со списком серийников, который у крупного УЦ весит мегабайты. OCSP — запрос к УЦ на каждое соединение: это лишний RTT, утечка истории посещений УЦ и, главное, soft-fail: если ответчик недоступен, клиент просто идёт дальше. Атакующий, укравший ключ, всего лишь блокирует OCSP — и отзыв бесполезен. OCSP stapling (сервер сам прикладывает свежий подписанный ответ) чинит latency и приватность, но не soft-fail; must-staple чинит и это, но ломает сайт при любом сбое стейплинга, поэтому почти не используется. Реальный ответ индустрии — не отзыв, а короткие сроки жизни: браузеры возят собственные компактные списки (Chrome CRLSets, Firefox CRLite), Let’s Encrypt в 2025 году вовсе прекратил обслуживать OCSP, а требования CA/Browser Forum давят срок сертификата вниз — 398 дней до 2026, 200 дней с марта 2026, дальше 100 и 47. Сертификат, живущий неделю, не нужно отзывать.

Certificate Transparency (RFC 9162) — это то, что реально сдерживает УЦ. Каждый публичный сертификат обязан попасть в несколько append-only логов с криптографическими доказательствами включения (SCT), иначе Chrome и Safari его не примут. Практический вывод для вас: любой ваш сертификат публичен, включая имена внутренних хостов. Ищутся они на crt.sh — заодно это лучший бесплатный мониторинг «кто выпустил сертификат на мой домен». Второй рычаг — запись CAA в DNS, которая явно перечисляет, каким УЦ разрешено выпускать сертификаты на ваш домен.

Отладка: живые команды и их вывод

openssl s_client — швейцарский нож

openssl s_client -connect example.com:443 -servername example.com </dev/null
CONNECTED(00000003)
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R11
verify return:1
depth=0 CN = example.com
verify return:1
---
Certificate chain
 0 s:CN = example.com
   i:C = US, O = Let's Encrypt, CN = R11
   a:PKEY: id-ecPublicKey, 256 (bit); sigalg: RSA-SHA256
   v:NotBefore: Jun 12 04:11:02 2026 GMT; NotAfter: Sep 10 04:11:01 2026 GMT
 1 s:C = US, O = Let's Encrypt, CN = R11
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---
SSL handshake has read 4231 bytes and written 401 bytes
Verification: OK
---
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Server public key is 256 bit
Secure Renegotiation IS NOT supported
ALPN protocol: h2
Early data was not sent
Verify return code: 0 (ok)
---
Post-Handshake New Session Ticket arrived:
SSL-Session:
    Resumption PSK: 9B2E...
    Max Early Data: 0

Разбор построчно — это и есть чек-лист диагностики:

  • depth=N ... verify return:1 — цепочка построилась целиком; depth считается от leaf (0) вверх.
  • Блок Certificate chain показывает ровно то, что прислал сервер. Если здесь только запись 0 и Verify return code: 21, сервер забыл промежуточный: в nginx подставили cert.pem вместо fullchain.pem.
  • SSL handshake has read 4231 bytes — размер серверного flight. Больше 10 КБ — повод перейти на ECDSA и выкинуть лишние звенья: этот flight должен влезать в начальное окно перегрузки TCP (обычно 10 сегментов ≈ 14 КБ), иначе к рукопожатию добавляется целый RTT.
  • Verify return code: 0 (ok) — единственная строка, которую действительно нужно грепать в скриптах.
  • ALPN protocol: h2 — согласован HTTP/2. Пусто означает, что клиент говорит по HTTP/1.1 (см. HTTP).
  • Отсутствие Post-Handshake New Session Ticket означает, что возобновления сессий не будет и каждое соединение будет полным рукопожатием.

Флаг -servername обязателен. Без него OpenSSL старых версий не шлёт SNI, и сервер отдаёт дефолтный виртуальный хост — вы будете отлаживать не тот сертификат. Ошибка unrecognized_name (alert 112) или «пришёл чужой сертификат» — почти всегда это.

Ещё несколько рецептов, которые стоит держать в мышечной памяти:

# Срок жизни и SAN без лишнего шума — годится для мониторинга
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
# notBefore=Jun 12 04:11:02 2026 GMT
# notAfter=Sep 10 04:11:01 2026 GMT
# X509v3 Subject Alternative Name: DNS:example.com, DNS:www.example.com

# Все сертификаты цепочки в файлы — когда нужно разобрать звенья по одному
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null

# Проверить, поддерживает ли сервер конкретную версию (нет вывода = не поддерживает)
openssl s_client -connect example.com:443 -servername example.com -tls1_2 </dev/null
openssl s_client -connect example.com:443 -servername example.com -tls1_3 </dev/null

# Проверить OCSP stapling: должен прийти OCSP Response Status: successful
openssl s_client -connect example.com:443 -servername example.com -status </dev/null | head -30

# Проверить возобновление сессии: сохранить и переиспользовать
openssl s_client -connect example.com:443 -servername example.com -sess_out /tmp/s.pem </dev/null
openssl s_client -connect example.com:443 -servername example.com -sess_in /tmp/s.pem </dev/null | grep -E 'Reused|New,'
# Reused, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384

# Клиентский сертификат для mTLS
openssl s_client -connect api.internal:8443 -cert client.crt -key client.key -CAfile ca.crt

# Проверить цепочку офлайн, без сети
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt -untrusted chain.pem leaf.pem

Коды Verify return code, которые встречаются чаще всего:

Код Текст Что на самом деле
0 ok всё хорошо
10 certificate has expired просрочен leaf или любое звено выше
18 self signed certificate самоподписанный, корня нет нигде
19 self signed certificate in certificate chain сервер прислал свой корень, а вы ему не верите
20 unable to get local issuer certificate нет корня в хранилище — типично для контейнеров
21 unable to verify the first certificate сервер не прислал промежуточный
62 hostname mismatch имя хоста не в SAN

curl -v — как выглядит то же самое глазами приложения

curl -vI --tlsv1.3 https://example.com/ 2>&1 | sed -n '1,25p'
* Connected to example.com (93.184.216.34) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=example.com
*  expire date: Sep 10 04:11:01 2026 GMT
*  subjectAltName: host "example.com" matched cert's "example.com"
*  issuer: C=US; O=Let's Encrypt; CN=R11
*  SSL certificate verify ok.
* using HTTP/2

Строка SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / x25519 / id-ecPublicKey содержит четыре ответа сразу: версия, AEAD, группа обмена ключами, тип ключа сертификата. Типовые сбои читаются мгновенно:

  • curl: (60) SSL certificate problem: unable to get local issuer certificate — в хранилище нет корня либо сервер не прислал промежуточный. Проверка: curl --cacert /path/to/chain.pem; заработало — виноват сервер, а не клиент.
  • curl: (35) ... sslv3 alert handshake failure — стороны не сошлись в версии, шифре или группе. Смотреть, что реально предлагает сервер: nmap --script ssl-enum-ciphers -p 443 host или testssl.sh.
  • curl: (60) SSL: no alternative certificate subject name matches target host name — имя хоста не в SAN. Часто симптом того, что вы попали не на тот бэкенд через балансировщик.

Полезные ключи для локализации проблемы: --resolve example.com:443:10.0.0.5 (проверить конкретный бэкенд с правильным SNI и Host, минуя DNS), --connect-to, --cert/--key для mTLS, -k только чтобы подтвердить, что дело именно в проверке сертификата (никогда в проде), --cert-status для проверки стейплинга.

Тайминги отдельно окупаются:

curl -o /dev/null -s -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://example.com/
# dns=0.021 tcp=0.058 tls=0.121 ttfb=0.184 total=0.190

time_appconnect − time_connect — это чистая стоимость рукопожатия TLS. Если она заметно больше одного RTT, ищите HelloRetryRequest, лишний RTT из-за большого flight или блокирующий OCSP-запрос.

tcpdump и Wireshark: что видно в дампе

# Только TLS-записи типа handshake (первый байт полезной нагрузки = 0x16)
sudo tcpdump -i any -nn -s0 'tcp port 443 and (tcp[((tcp[12:1] & 0xf0) >> 2)] = 0x16)'

# Строго ClientHello: добавляем проверку типа сообщения 0x01 через пять байт заголовка записи
sudo tcpdump -i any -nn 'tcp port 443 and (tcp[((tcp[12:1] & 0xf0) >> 2)] = 0x16) and (tcp[((tcp[12:1] & 0xf0) >> 2) + 5] = 0x01)'

# Дамп в файл для Wireshark — обязательно -s0, иначе записи обрежутся
sudo tcpdump -i eth0 -s0 -w /tmp/tls.pcap 'host example.com and tcp port 443'

Здоровое рукопожатие в Wireshark выглядит ровно так, и любое отклонение диагностично:

No.  Time      Source        Destination   Proto    Info
1    0.000000  10.0.0.7      93.184.216.34 TCP      54321 → 443 [SYN] Seq=0 WS=128
2    0.031412  93.184.216.34 10.0.0.7      TCP      443 → 54321 [SYN, ACK] Seq=0 Ack=1
3    0.031498  10.0.0.7      93.184.216.34 TCP      54321 → 443 [ACK] Seq=1 Ack=1
4    0.032104  10.0.0.7      93.184.216.34 TLSv1.3  Client Hello (SNI=example.com)
5    0.063800  93.184.216.34 10.0.0.7      TLSv1.3  Server Hello, Change Cipher Spec, Application Data
6    0.064210  93.184.216.34 10.0.0.7      TLSv1.3  Application Data          ← это Certificate и CertificateVerify
7    0.065002  10.0.0.7      93.184.216.34 TLSv1.3  Change Cipher Spec, Application Data
8    0.096500  93.184.216.34 10.0.0.7      TLSv1.3  Application Data, Application Data  ← NewSessionTicket

Читается так: между кадрами 3 и 5 ровно один RTT (31 мс) — это и есть цена рукопожатия TLS 1.3. Всё, что после ServerHello, помечено как Application Data, потому что настоящий тип спрятан — не пугайтесь, это норма для 1.3. В TLS 1.2 на месте кадра 6 вы бы увидели явные Certificate, Server Key Exchange, Server Hello Done и смогли бы вытащить сертификат прямо из дампа (File → Export Objects бесполезен, а вот клик по сертификату в дереве и «Export Packet Bytes» — работает).

Полезные фильтры отображения:

tls.handshake.type == 1                       # ClientHello
tls.handshake.extensions_server_name == "example.com"
tls.handshake.type == 11                      # Certificate (виден только в TLS 1.2)
tls.record.content_type == 21                 # alert — красный флаг, рядом tls.alert_message.desc
tcp.analysis.retransmission && tcp.port == 443

Как выглядит потеря пакета в рукопожатии. Самая частая продакшн-картина: ClientHello ушёл, ServerHello пришёл, а серверный flight с сертификатом (4–8 КБ, то есть 3–6 полных сегментов) не доходит. В дампе на клиенте вы видите пустоту, а на сервере — повторы одних и тех же сегментов через 200 мс, 400 мс, 800 мс, 1.6 с (экспоненциальный откат RTO), после чего соединение отваливается по таймауту. Диагноз почти всегда один: чёрная дыра MTU — где-то на пути туннель с MTU меньше 1500, а ICMP «fragmentation needed» отфильтрован файрволом. Проверяется за минуту: ping -M do -s 1472 example.com (пакет ровно в 1500 байт с заголовками) и tracepath example.com; лечится MSS clamping на туннеле — iptables -t mangle -A FORWARD -p tcp --syn -j TCPMSS --clamp-mss-to-pmtu. Подробнее про Path MTU Discovery — в статье про IP и маршрутизацию.

Второй частый узор — потеря внутри уже установленного TLS-соединения. Поскольку TLS-запись может быть размазана по нескольким сегментам, потеря одного сегмента блокирует всю запись: приложение не получит ни байта, пока не приедет ретрансмиссия. Это тот самый head-of-line blocking, только с дополнительным множителем от размера записи. Отсюда практический совет: для интерактивного трафика уменьшайте размер записи (в nginx — ssl_buffer_size 4k вместо 16k), для потоковой отдачи — наоборот увеличивайте.

Расшифровка дампа. С ECDHE расшифровать трафик приватным ключом сервера невозможно — в этом и смысл forward secrecy. Единственный законный способ — журнал ключей на одной из сторон:

export SSLKEYLOGFILE=/tmp/keys.log
curl https://example.com/ >/dev/null    # то же понимают Chrome и Firefox
# Wireshark → Preferences → Protocols → TLS → (Pre)-Master-Secret log filename → /tmp/keys.log

После этого Wireshark показывает расшифрованный HTTP/2 прямо в дереве. Это лучший в мире способ разобраться, что именно ваш клиент отправляет — и одновременно повод понимать, что тот же трюк доступен любому, кто контролирует машину пользователя.

Жизненный цикл соединения

Два состояния заслуживают отдельного слова.

TRUNCATED — обрыв без close_notify. Штатное закрытие TLS требует обмена алертами close_notify; только они гарантируют, что противник не «обрезал» конец потока. На практике половина серверов просто рвёт TCP, и библиотеки логируют unexpected eof while reading (Python) или ошибка чтения. Для HTTP это обычно безобидно (длина известна из Content-Length), но для потоковых протоколов без явных длин — это реальная truncation attack.

EARLY_DATA — 0-RTT и его цена. Имея билет с прошлой сессии, клиент шлёт данные вместе с ClientHello: нулевая задержка до первого байта. Расплата — отсутствие защиты от повтора: 0-RTT данные не участвуют в свежем обмене, и атакующий может записать пакет и переиграть его сколько угодно раз. Правило простое: 0-RTT только для идемпотентных операций. В nginx это ssl_early_data on;, и приложение обязано проверять заголовок Early-Data: 1 (RFC 8470) и отвечать 425 Too Early на всё, что меняет состояние. Отключить проще, чем правильно поддержать, — и это нормальный выбор по умолчанию.

Производительность: где TLS реально стоит денег

Симметричное шифрование давно не проблема: AES-GCM с AES-NI делает гигабайты в секунду на ядро, ChaCha20-Poly1305 быстр там, где AES-NI нет (мобильные ARM без ускорителя). Дорого другое.

Что стоит Порядок Как чинить
Полное рукопожатие 1 RTT (1.3) поверх 1 RTT TCP пул соединений, HTTP/2, QUIC с 0-RTT
Подпись RSA-2048 на сервере ~1 мс CPU ECDSA P-256 — примерно на порядок дешевле подписи
Размер цепочки RSA ~4 КБ vs ECDSA ~1.5 КБ выкинуть корень из fullchain, перейти на ECDSA
Flight не влез в начальное окно +1 RTT держать серверный flight под ~10 КБ
Синхронный OCSP-запрос +1 RTT и внешняя зависимость OCSP stapling с кэшем на сервере
Копирование при шифровании заметно на отдаче файлов kTLS + sendfile, TLS-offload на NIC

Возобновление сессий — самая недооценённая настройка. Билет (NewSessionTicket) шифруется симметричным ключом сервера. Если за балансировщиком стоят пять nginx с разными ключами билетов, клиент почти всегда попадает не на тот сервер, билет не расшифровывается и каждое соединение оказывается полным рукопожатием. Метрика ssl_session_reuse в логах внезапно 15% вместо 90%, а вы ищете проблему в приложении. Лечится общим набором ключей, который ротируется по расписанию:

# все узлы читают одни и те же файлы; первый — для шифрования, остальные — для расшифровки старых билетов
ssl_session_ticket_key /etc/nginx/ticket/current.key;
ssl_session_ticket_key /etc/nginx/ticket/prev.key;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;   # ~200 000 сессий на 50 МБ

Ключи обязаны ротироваться (иначе теряется forward secrecy: укравший ключ билетов расшифрует все сохранённые сессии) и обязаны быть одинаковыми на всех узлах. Это ровно тот случай, когда «включили безопасность» без ротации хуже, чем выключенные билеты.

Проверить, что возобновление реально работает, можно за десять секунд — командой -sess_out/-sess_in из раздела выше: ищите Reused вместо New,.

Продакшн-конфигурация и типовые грабли

Минимальный вменяемый TLS в nginx на 2026 год:

server {
    listen 443 ssl;
    http2 on;

    # fullchain, а не cert! leaf + промежуточные, БЕЗ корня.
    # Две пары сразу: старый клиент получит RSA, современный — дешёвый ECDSA
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_certificate     /etc/letsencrypt/live/example.com-ecc/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com-ecc/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;            # в 1.3 порядок клиента разумнее
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305;
    ssl_ecdh_curve X25519:prime256v1;

    ssl_session_cache shared:SSL:50m;
    ssl_session_timeout 1d;
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
    resolver 1.1.1.1 8.8.8.8 valid=300s;      # без резолвера стейплинг молча не заработает

    # HSTS выдавать ТОЛЬКО по HTTPS и только когда уверены в сроках
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
}

Грабли, на которые наступают чаще всего:

  • ssl_certificate cert.pem вместо fullchain.pem. Симптом: в браузере всё хорошо (он дотянул промежуточный по AIA), в CI и у мобильного приложения — unable to verify the first certificate. Проверяется одной командой openssl s_client: смотрите, сколько сертификатов в блоке Certificate chain.
  • Прокси ходит на бэкенд с неправильным SNI. В nginx proxy_pass https://backend; не отправляет SNI по умолчанию и не проверяет сертификат. Нужны proxy_ssl_server_name on; proxy_ssl_name $host; proxy_ssl_verify on; proxy_ssl_trusted_certificate .... Умолчания здесь небезопасные — это надо просто помнить. Подробности про схемы терминации — в статье про прокси и балансировку.
  • HSTS с большим max-age до того, как всё работает. Заголовок кэшируется в браузере на два года и сделать «откат на HTTP» нельзя. Выкатывайте с max-age=300, повышайте постепенно; в preload-список подавайте только когда абсолютно уверены во всех поддоменах.
  • Часы. Контейнер или embedded-устройство без синхронизации времени даёт «сертификат ещё не действителен». Это не проблема TLS, это NTP.
  • Хранилище доверия в контейнере. FROM alpine без apk add ca-certificates — и любое HTTPS-соединение падает с кодом 20. В FROM scratch для Go-бинарника нужно копировать /etc/ssl/certs/ca-certificates.crt явно.
  • Отключённая проверка «чтобы заработало». verify=False в requests, InsecureSkipVerify: true в Go, -k в curl. Это не «временное решение»: оно превращает TLS в шифрование без аутентификации, то есть в защиту от пассивного наблюдателя и ноль защиты от активного. Если нужен внутренний УЦ — добавляйте его в trust store, а не выключайте проверку.
  • Пиннинг сертификатов. HPKP в браузерах умер именно потому, что им регулярно «замуровывали» собственные сайты. В мобильных приложениях пиннинг ещё жив, но пиньте публичный ключ промежуточного, а не leaf, держите резервный пин и план обновления. Для большинства задач CAA + мониторинг CT дают тот же эффект без риска самоубийства.

Мониторинг сроков — то, что должно быть у всех

Инцидент «истёк сертификат» до сих пор входит в топ причин массовых падений, потому что 90-дневный сертификат ломается, когда тихо сломался ACME-клиент. Минимальная проверка на Python без внешних зависимостей:

import socket, ssl, datetime

def days_left(host: str, port: int = 443, timeout: float = 5.0) -> int:
    """Сколько дней осталось у сертификата хоста. Проверка цепочки включена."""
    ctx = ssl.create_default_context()          # системное хранилище + проверка имени
    with socket.create_connection((host, port), timeout) as sock:
        with ctx.wrap_socket(sock, server_hostname=host) as tls:   # server_hostname = SNI
            not_after = tls.getpeercert()["notAfter"]              # 'Sep 10 04:11:01 2026 GMT'
    expires = datetime.datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z")
    return (expires - datetime.datetime.utcnow()).days

for host in ("example.com", "api.example.com"):
    d = days_left(host)
    print(f"{host}: {d} дней", "ВНИМАНИЕ" if d < 21 else "")

Сложность — O(1) по CPU и один RTT сети на хост; для сотен доменов запускайте пул потоков, узкое место сетевое, а не вычислительное. Порог в 21 день выбран не случайно: при 90-дневном сертификате Let’s Encrypt обновляет за 30 дней до конца, и три недели тишины — это уже сломанная автоматизация, а не «скоро истечёт». Эту же проверку разумно повесить в общий мониторинг рядом с остальными SLO — см. наблюдаемость.

Для разовой глубокой проверки конфигурации используйте testssl.sh (запускается локально, не светит внутренние хосты наружу) или SSL Labs для публичных сайтов. Готовые конфиги — Mozilla SSL Configuration Generator: три профиля (modern, intermediate, old) и всегда актуальные списки шифров.

mTLS: когда клиент тоже предъявляет паспорт

В обычном HTTPS аутентифицируется только сервер. Взаимный TLS добавляет CertificateRequest от сервера и Certificate + CertificateVerify от клиента — те же самые проверки, только в обратную сторону. Плюсы очевидны: аутентификация на уровне транспорта, никаких токенов в заголовках, работает для любого протокола. Минус ровно один, но огромный: вы становитесь удостоверяющим центром. Выпуск, доставка, ротация и отзыв клиентских сертификатов — это инфраструктура, которую надо построить и содержать.

Поэтому mTLS живёт в двух нишах. Первая — межсервисное взаимодействие внутри кластера, где всё автоматизировано: service mesh выдаёт каждому поду короткоживущий сертификат с идентичностью SPIFFE (spiffe://cluster/ns/prod/sa/payments), ротирует его каждый час, и отзыв просто не нужен — сертификат умирает сам. Вторая — B2B-интеграции и платёжные API, где сертификат выдаётся вручную одному партнёру. Для пользовательской аутентификации mTLS почти всегда проигрывает обычным токенам: пользователи теряют ключи, а UX установки сертификата в браузер отвратителен.

Практическая деталь: при mTLS через балансировщик сертификат клиента терминируется на балансировщике, и бэкенд должен получать идентичность через доверенный заголовок (X-Client-Cert, X-SSL-Client-S-DN) — а значит балансировщик обязан затирать эти заголовки из внешнего запроса. Забыли затереть — получили подделку идентичности одним curl.

Что впереди: ECH и постквантовый обмен ключами

Encrypted Client Hello закрывает последнюю крупную утечку — имя сайта в SNI. Схема такая: сервер публикует публичный ключ ECH в записи HTTPS (тип RR 65) в DNS, клиент шифрует настоящий ClientHello этим ключом и отправляет «внешний» ClientHello с именем-обёрткой (например, общим именем CDN). Наблюдатель видит только, что вы пошли на CDN. Обратите внимание на зависимость: ECH бесполезен без защищённого DNS, иначе имя утечёт в запросе резолверу — поэтому ECH идёт в паре с DoH/DoT (DNS). Развёрнут в Cloudflare и включён в Firefox и Chrome; в корпоративных сетях, где DPI требует видеть SNI, его блокируют — и это делает ECH политически заряженной технологией.

Постквантовая криптография. Угроза «harvest now, decrypt later» реальна: трафик, записанный сегодня, расшифруют, когда появится достаточно большой квантовый компьютер. Обмен ключами уже мигрировал: гибрид X25519MLKEM768 (классический X25519 + ML-KEM из FIPS 203) включён по умолчанию в Chrome и на серверах Cloudflare с 2024–2025 годов. Гибрид — потому что ML-KEM молод и его хотят подстраховать проверенным X25519. Практическое следствие, о котором стоит знать: ключ ML-KEM большой, ClientHello вырос примерно с 300 байт до 1.2–2 КБ и перестал влезать в один TCP-сегмент — часть старых middlebox’ов, ожидавших ClientHello в первом пакете, ломается. Если после обновления браузера часть пользователей перестала открывать сайт, это первый подозреваемый. Подписи пока классические: постквантовые подписи дают цепочки на десятки килобайт, и индустрия ещё ищет, как это пережить.

Мини-итог

  • TLS решает три разные задачи: конфиденциальность и целостность (дёшево, симметрика) и аутентификацию (дорого, вся индустрия УЦ). Проблемы почти всегда в третьей.
  • TLS — отдельный уровень со своим фреймингом. Записи не совпадают с сегментами TCP, заголовок записи в 1.3 намеренно врёт ради middlebox’ов.
  • Рукопожатие 1.3 стоит 1 RTT (плюс RTT на TCP), потому что клиент угадывает группу заранее. HelloRetryRequest возвращает вас во времена TLS 1.2.
  • CertificateVerify доказывает владение приватным ключом в этом соединении, Finished защищает транскрипт от подмены и downgrade.
  • Валидность сертификата — свойство тройки «сертификат + хранилище + время + имя хоста», а не самого файла. Отсюда «в браузере работает, в контейнере нет».
  • Отзыв работает плохо (soft-fail OCSP), поэтому индустрия идёт в короткие сроки жизни: 398 → 200 → 100 → 47 дней. Certificate Transparency сдерживает УЦ лучше, чем отзыв.
  • Отладка: openssl s_client -servername (цепочка, версия, ALPN, стейплинг), curl -v с таймингами, tcpdump с фильтром по 0x16, Wireshark с SSLKEYLOGFILE. Потеря пакета в рукопожатии выглядит как ретрансмиссии серверного flight и почти всегда означает чёрную дыру MTU.
  • Самый окупаемый тюнинг: fullchain.pem, ECDSA-сертификат, общие ключи билетов на всём флоте, OCSP stapling с резолвером, мониторинг срока за 21 день. 0-RTT — только для идемпотентного, verify=False — никогда.

Источники

Что дальше

HTTPS-канал установлен, и по нему прекрасно ездят запрос-ответ. Но приложениям постоянно нужно другое: сервер должен сам сказать клиенту, что что-то произошло. Опрос в цикле дорог и медленен, а держать соединение открытым можно очень разными способами — с очень разной ценой.

Реальное время: WebSocket, SSE, long polling, WebRTC — про то, как из обычного HTTP-соединения вырастает двунаправленный канал, чем Upgrade отличается от бесконечного ответа, почему SSE недооценён, и когда без WebRTC действительно не обойтись.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов