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 — это не «шифрование 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
считает ECDHE и выводит handshake-ключи S->>C: ServerHello + key_share(X25519) + selected_cipher Note over C,S: с этой точки всё зашифровано S->>C: EncryptedExtensions — согласованный ALPN и прочее S->>C: Certificate — leaf + промежуточные S->>C: CertificateVerify — подпись по хешу транскрипта S->>C: Finished — HMAC по всему транскрипту Note over C: строит цепочку, сверяет SAN с именем хоста,
проверяет подпись CertificateVerify C->>S: Finished + сразу первый HTTP-запрос S->>C: NewSessionTicket ×2 и ответ приложения
Что здесь важно и неочевидно:
- Клиент угадывает группу заранее. В 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 — это структура 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; валиден сегодня и невалиден завтра. Отсюда почти все «магические» инциденты.
Алгоритм проверки, который выполняет клиент
до корня из хранилища?"} B -- "нет" --> B1["unable to get local issuer certificate
код 20 или 21 — почти всегда
забыт промежуточный или пуст trust store"] B -- "да" --> C{"Все подписи в пути
математически верны?"} C -- "нет" --> C1["certificate signature failure
подмена или битый файл"] C -- "да" --> D{"Текущее время внутри
notBefore..notAfter у ВСЕХ звеньев?"} D -- "нет" --> D1["certificate has expired
или not yet valid — проверьте NTP"] D -- "да" --> E{"У промежуточных CA:TRUE,
pathlen соблюдён, есть keyCertSign?"} E -- "нет" --> E1["invalid CA certificate"] E -- "да" --> F{"Имя хоста совпало
с DNS-именем в SAN?"} F -- "нет" --> F1["hostname mismatch
частая причина — обращение по IP
или неверный SNI за прокси"] F -- "да" --> G{"EKU содержит serverAuth?"} G -- "нет" --> G1["unsupported certificate purpose"] G -- "да" --> H{"Не отозван?
CRL / OCSP / CRLite"} H -- "отозван" --> H1["certificate revoked"] H -- "ок или неизвестно" --> I{"Достаточно SCT
из Certificate Transparency?"} I -- "нет" --> I1["Chrome: ERR_CERTIFICATE_TRANSPARENCY_REQUIRED"] I -- "да" --> J["Проверить CertificateVerify —
подпись по транскрипту приватным ключом leaf"] J --> K["Соединение доверенное"]
Обратите внимание на два узла, где на практике теряют больше всего времени.
Построение пути — не «идти по 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— никогда.
Источники
- RFC 8446 — TLS 1.3; RFC 5246 — TLS 1.2; RFC 8470 — Using Early Data in HTTP.
- RFC 5280 — X.509 и алгоритм построения пути; RFC 6125 — проверка имени хоста; RFC 9162 — Certificate Transparency 2.0; RFC 8659 — DNS CAA; RFC 8555 — ACME.
- The Illustrated TLS 1.3 Connection — байт за байтом весь handshake, лучший материал для «наконец понял».
- Ivan Ristić. Bulletproof TLS and PKI, 2-е издание — исчерпывающая книга по эксплуатации; feistyduck.com.
- Mozilla SSL Configuration Generator и Mozilla Server Side TLS — источник актуальных конфигов.
- testssl.sh, SSL Labs Server Test, crt.sh — три инструмента, закрывающие 90% проверок.
- Let’s Encrypt: Chain of Trust и пост об окончании поддержки OCSP.
- Cloudflare Blog — The state of the post-quantum Internet и Encrypted Client Hello.
- OpenSSL s_client(1) — единственный достоверный справочник по флагам.
Что дальше
HTTPS-канал установлен, и по нему прекрасно ездят запрос-ответ. Но приложениям постоянно нужно другое: сервер должен сам сказать клиенту, что что-то произошло. Опрос в цикле дорог и медленен, а держать соединение открытым можно очень разными способами — с очень разной ценой.
Реальное время: WebSocket, SSE, long polling, WebRTC — про то, как из обычного HTTP-соединения вырастает двунаправленный канал, чем Upgrade отличается от бесконечного ответа, почему SSE недооценён, и когда без WebRTC действительно не обойтись.