Транспортная безопасность: TLS, сертификаты, mTLS, пиннинг
Между вашим кодом и кодом собеседника лежит сеть, и это самая враждебная часть системы: вы не владеете ни одним её метром. Wi-Fi в кофейне, домашний роутер с прошивкой 2014 года, оператор связи, транзитный автономный система, чей BGP-анонс сегодня кто-то перехватил, DNS-резолвер, отвечающий не то, о чём его спросили, и корпоративный прокси, который «для безопасности» вскрывает весь трафик. RFC 3552 формулирует базовое допущение интернет-модели угроз прямо: считайте, что противник полностью контролирует канал — читает, задерживает, повторяет, подменяет и вставляет свои сообщения. Это модель Долева–Яо, и проектировать транспорт нужно только под неё.
Аналогия, которая держится до самого конца: открытка против запечатанного конверта, вручённого лично. HTTP — открытка: её читает каждый почтальон, и любой может подменить текст. Шифрование без аутентификации — конверт, но отданный неизвестно кому: содержимое скрыто, а получатель, возможно, самозванец. TLS — конверт плюс проверка удостоверения получателя, где удостоверение выдаёт третья сторона, которой вы решили верить. Вся сложность транспортной безопасности живёт именно в последней фразе: кому и почему вы решили верить, и что происходит, когда это решение оказывается неверным.
Статья идёт по той же схеме, что и остальной трек: как выглядит уязвимый код → почему это работает → как чинить правильно → как проверить, что починено. Криптографические примитивы под капотом (AEAD, ECDHE, подписи, KDF) разбираются в прикладной криптографии; здесь мы про протокол, PKI и эксплуатацию. Модель нарушителя и границы доверия — в моделировании угроз.
Правила игры
Всё, что здесь описано, применяется только к системам, которыми вы владеете, или к тем, на тестирование которых у вас есть письменное разрешение владельца — договор с зафиксированным периметром, окнами проведения и rules of engagement. Сканирование чужого TLS-эндпоинта, попытка встать посередине чужого соединения, перехват трафика в общедоступной сети — это не исследование, а неправомерный доступ, независимо от намерений. Даже публичные онлайн-сканеры вроде Qualys SSL Labs в условиях использования требуют проверять только домены, которыми вы управляете.
Тренироваться нужно на своём стенде: локальный CA через mkcert или step-ca от Smallstep, пара контейнеров, прокси, который вы сами поставили посередине своего же трафика. Это даёт всё то же понимание и не создаёт юридических проблем.
Как транспорт учился на поломках
Историю TLS полезно читать как хронику разборов инцидентов: почти каждая версия и каждое расширение закрывают конкретный класс ошибок, который до этого стоил индустрии денег.
Три вывода из этой ленты, которые задают тон всей статье. Первый: почти ничего не сломалось из-за слабости самой криптографии — ломались режимы работы, откаты на старые версии, сжатие, реализации и операционные процессы. Второй: всё, что можно было настроить неправильно, настраивали неправильно, поэтому TLS 1.3 радикально сократил пространство выбора. Третий: сроки жизни всего сокращаются — сертификатов, ключей, идентичностей, — потому что короткий срок оказался практичнее любых механизмов отзыва.
Что TLS даёт и чего не даёт
Три свойства, которые даёт корректно настроенный TLS:
| Свойство | Что означает | Чем обеспечено |
|---|---|---|
| Конфиденциальность | посторонний не читает содержимое | AEAD-шифрование записей (AES-GCM, ChaCha20-Poly1305) |
| Целостность | посторонний не изменит содержимое незаметно | тег аутентификации AEAD + MAC над транскриптом рукопожатия |
| Аутентичность сервера | вы говорите с тем, чьё имя запросили | сертификат + подпись CertificateVerify + проверка имени хоста |
| Прямая секретность | вчерашний трафик не расшифруют завтрашним ключом | эфемерный ECDHE в каждом рукопожатии |
И длинный список того, чего TLS не даёт, — именно здесь рождаются ложные ощущения безопасности:
- Не защищает от того, кому вы сами отдали данные. Аутентифицированный канал к мошенническому сайту — по-прежнему аутентифицированный канал. Замочек в адресной строке означает «канал до этого домена цел», а не «этот домен честный».
- Не скрывает метаданные. IP собеседника, объёмы, тайминги, размеры пакетов, ALPN и, до внедрения Encrypted Client Hello, поле SNI с именем хоста — всё это видно на проводе. По одним размерам ответов можно различать страницы внутри сайта.
- Не защищает данные на концах. На узле, где TLS терминируется, трафик существует в открытом виде — в памяти процесса, в логах, в дампах, в заголовках, которые пишет балансировщик.
- Не заменяет авторизацию. Предъявленный клиентский сертификат отвечает на вопрос «кто это», а не «что этому кому-то можно». Про второй вопрос — авторизация.
- Не гарантирует, что противник не сохранил трафик на будущее. Отсюда мотивация «harvest now, decrypt later» и переход на гибридный постквантовый обмен ключами.
В классификации OWASP всё это — A02:2021 Cryptographic Failures, а в CWE ключевые записи: CWE-319 передача в открытом виде, CWE-295 некорректная проверка сертификата, CWE-297 несовпадение имени хоста, CWE-757 выбор менее защищённого алгоритма при согласовании.
Рукопожатие TLS 1.3: что и когда становится тайной
TLS 1.3 (RFC 8446) устроен так, что полное рукопожатие укладывается в один круговой обход, а всё, кроме первых двух сообщений, уже зашифровано.
Разберём то, что обычно проскакивает мимо внимания.
key_share в первом же сообщении. Клиент угадывает, какую группу выберет сервер, и сразу шлёт свою эфемерную публичную часть. Угадал — рукопожатие в один RTT. Не угадал — сервер отвечает HelloRetryRequest и просит другую группу, это стоит лишнего круга. Эфемерность здесь не опция: в TLS 1.3 обмен ключами всегда даёт прямую секретность, статический RSA key transport из TLS 1.2 удалён именно потому, что утечка ключа сервера расшифровывала весь ранее записанный трафик.
CertificateVerify — сердце аутентификации. Сертификат сам по себе не доказывает ничего: его копию может прислать кто угодно, он публичен. Доказательством служит подпись приватным ключом над хешем всего транскрипта рукопожатия. Привязка к транскрипту означает, что подпись нельзя переиспользовать в другом соединении, и что противник, подменивший хоть один байт в предыдущих сообщениях, ломает проверку.
Finished — защита от отката. Это MAC над полным транскриптом на ключе, выведенном из общего секрета. Если противник вырезал из ClientHello упоминание TLS 1.3, чтобы стороны договорились о слабой версии, транскрипты у сторон разойдутся и Finished не сойдётся. Плюс в TLS 1.3 добавлен явный «канареечный» механизм: сервер, соглашаясь на TLS 1.2 по требованию клиента, обязан записать в последние байты ServerHello.random фиксированную метку, а клиент, поддерживающий 1.3, обязан на неё среагировать. Это и есть системный ответ на класс атак понижения версии.
Возобновление сессии и 0-RTT. После первого рукопожатия сервер может выдать NewSessionTicket, и следующее соединение поднимается по PSK — дешевле по CPU и по кругам. Режим 0-RTT (early data) позволяет клиенту отправить данные вместе с самым первым сообщением, но у него есть неустранимое свойство: early data не защищена от повтора. Противник, записавший этот блок, может воспроизвести его на сервере. Приложение RFC 8446 (Appendix E.5) прямо требует пропускать в 0-RTT только идемпотентные операции. Практическое правило: 0-RTT — только для GET без побочных эффектов, никогда для платежей, изменения состояния или чего-либо, что нельзя выполнить дважды. И отдельно: ключи шифрования тикетов сессии нужно ротировать (в nginx это ssl_session_ticket_key с регулярной сменой), иначе прямая секретность превращается в фикцию — постоянный ключ тикетов расшифровывает записанные сессии задним числом.
Стоимость рукопожатия стоит держать в голове при проектировании:
| Сценарий | Круговые обходы до первых данных | Что доминирует по CPU |
|---|---|---|
| TLS 1.2, полное рукопожатие | 2 RTT | асимметрика: подпись и обмен ключами |
| TLS 1.3, полное рукопожатие | 1 RTT | одна подпись сервера + ECDH |
| TLS 1.3, возобновление по PSK | 1 RTT | только симметрика |
| TLS 1.3, 0-RTT | 0 RTT | симметрика, но с риском повтора |
| QUIC + TLS 1.3 (RFC 9001) | 1 RTT вместе с установкой транспорта | симметрика, шифрование в user space |
Асимметричные операции при рукопожатии на порядки дороже потокового шифрования: подпись ECDSA P-256 — десятки микросекунд, RSA-2048 — около миллисекунды, а AES-GCM с аппаратной поддержкой AES-NI обрабатывает гигабайты в секунду. Отсюда практический вывод: узкое место — не «шифрование трафика», а частота новых рукопожатий. Keep-alive, возобновление сессий и HTTP/2-мультиплексирование дают больше, чем любая экономия на криптографии.
Конфигурация: чем меньше выбора, тем лучше
Базовый документ — RFC 9325 (BCP 195, «Recommendations for Secure Use of TLS and DTLS») и NIST SP 800-52 Rev. 2. Практический генератор конфигов — Mozilla SSL Configuration Generator, профиль intermediate подходит почти всем.
# nginx: минимальный набор, который стоит скопировать и понять
ssl_protocols TLSv1.2 TLSv1.3; # 1.0/1.1 запрещены RFC 8996
ssl_prefer_server_ciphers off; # в 1.3 порядок клиента разумнее
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_ecdh_curve X25519:prime256v1:secp384r1;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off; # либо on + ротация ssl_session_ticket_key
ssl_stapling on; # если CA ещё поддерживает OCSP
ssl_stapling_verify on;
# HSTS ставим ТОЛЬКО на https-ответах, иначе заголовок бессмыслен
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Что должно быть выключено и почему:
- SSL 3.0, TLS 1.0, TLS 1.1 — RFC 8996, формально устарели, тянут за собой CBC и MD5/SHA-1.
- RC4 — RFC 7465, статистические смещения в потоке.
- 3DES — блок 64 бита, атака Sweet32 на длинных соединениях.
- Наборы без AEAD (CBC в TLS 1.2) — источник BEAST и Lucky13; если нужны для совместимости, только с расширением encrypt-then-MAC.
- Сжатие на уровне TLS — CRIME; сжатие HTTP-ответов с секретами в теле — BREACH, лечится не на транспорте, а разделением секретов и пользовательского ввода.
- Небезопасное пересогласование — RFC 5746; в TLS 1.3 пересогласования нет вовсе.
- Экспортные наборы, NULL-шифры, анонимный DH — FREAK, Logjam и родня.
Тот же набор решений в коде сервера на Go:
// Сервер: явно фиксируем нижнюю границу версии и кривые.
srv := &http.Server{
Addr: ":8443",
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12, // ниже — не согласовываем вообще
CurvePreferences: []tls.CurveID{
tls.X25519, tls.CurveP256,
},
// Для TLS 1.2 ограничиваем список; TLS 1.3 набор задаёт сам и правильно.
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
},
},
}
Отдельно про постквантовую подготовку. Записанный сегодня трафик может быть расшифрован завтрашним криптоаналитическим средством — это и есть «harvest now, decrypt later». Индустрия отвечает гибридным обменом ключами: X25519MLKEM768 совмещает классический X25519 и ML-KEM (FIPS 203), и итоговый секрет безопасен, пока безопасна хотя бы одна из половин. В свежих браузерах и в OpenSSL 3.5 это уже включается по умолчанию; со стороны сервера достаточно не мешать — не фиксировать жёстко список групп устаревшим списком. Подписи в сертификатах пока классические, и это нормально: подпись нужно взломать в момент соединения, а не спустя годы.
Что такое сертификат на самом деле
Сертификат — это заверенное третьей стороной утверждение «этот открытый ключ принадлежит владельцу вот этих имён». Формат — X.509 v3, профиль для интернета — RFC 5280, правила выпуска публичными CA — CA/Browser Forum Baseline Requirements.
Ключевые нюансы, из которых растут ошибки:
Имена берутся только из subjectAltName. Поле CN в subject — историческое, RFC 9525 (заменивший RFC 6125) требует полагаться исключительно на SAN, и публичные CA давно обязаны его заполнять. Библиотека, которая при отсутствии SAN проваливается на CN, ведёт себя опасно.
Wildcard ограничен одним уровнем. *.example.com покрывает api.example.com, но не a.b.example.com и не сам example.com. Wildcard на публичный суффикс (*.com, *.co.uk) выпускать запрещено. Каждый wildcard — это один приватный ключ, компрометация которого стоит всего поддомена сразу; в проде дешевле выпускать отдельные сертификаты автоматикой.
Срок жизни сокращается по расписанию. Максимум был 825 дней, затем 398. По решению CA/Browser Forum (ballot SC-081) он снижается ступенями до 47 дней к 2029 году, вместе с сокращением срока переиспользования подтверждения владения доменом. Читать это надо не как «стало неудобнее», а как «ручной выпуск сертификатов официально закончился»: если у вас нет автоматизации на ACME, её нужно строить сейчас.
Промежуточные сертификаты присылает сервер. Корень лежит у клиента, лист присылает сервер, а промежуточные — обязанность сервера. Классическая авария «у меня в браузере работает, а из curl и с Android — нет» почти всегда означает недосланную промежуточную цепочку: десктопный браузер вытянул её по ссылке в AIA, а строгий клиент — нет.
Алгоритм проверки: что клиент обязан сделать
до корня в хранилище?"} B -- нет --> X1["ОТКАЗ: unknown authority"] B -- да --> C{"Подпись каждого звена
проверяется ключом издателя?"} C -- нет --> X2["ОТКАЗ: invalid signature"] C -- да --> D{"Текущее время внутри
notBefore..notAfter у всех?"} D -- нет --> X3["ОТКАЗ: expired / not yet valid"] D -- да --> E{"У промежуточных CA:TRUE,
pathLen не превышен?"} E -- нет --> X4["ОТКАЗ: не CA или слишком длинный путь"] E -- да --> F{"keyUsage и extKeyUsage
допускают serverAuth?"} F -- нет --> X5["ОТКАЗ: сертификат не для этой роли"] F -- да --> G{"Соблюдены nameConstraints
вышестоящих CA?"} G -- нет --> X6["ОТКАЗ: имя вне разрешённого поддерева"] G -- да --> H{"Сертификат не отозван
по CRL / OCSP / CRLite?"} H -- отозван --> X7["ОТКАЗ: revoked"] H -- нет данных --> H2["soft-fail: многие клиенты продолжают"] H -- не отозван --> I{"Имя хоста из URL совпадает
с subjectAltName?"} H2 --> I I -- нет --> X8["ОТКАЗ: hostname mismatch (CWE-297)"] I -- да --> J["Соединение доверенное"]
Два шага из этой схемы обеспечивают почти всю статистику уязвимостей.
Первый — проверка имени хоста. Её нет в RFC 5280: тот описывает построение и валидацию пути, но не связь с URL, к которому вы обращаетесь. Связь описана отдельно (RFC 9525), и исторически огромное число библиотек её не выполняло по умолчанию. Каноническое исследование — «The Most Dangerous Code in the World: Validating SSL Certificates in Non-Browser Software» (Georgiev et al., CCS 2012): в платёжных SDK, облачных клиентах и middleware проверка была сломана массово. Без этого шага противник с любым валидным сертификатом на любой домен, который он контролирует, встаёт посередине вашего соединения.
Второй — отзыв, и он до сих пор не решён (см. следующий раздел).
Уязвимый код №1: отключённая проверка
Как это выглядит. Все примеры ниже — реальные строки, которые встречаются в продакшне; обычно они появляются, когда «на тесте самоподписанный сертификат» или «в компании MITM-прокси, и мешает ошибка».
# ПЛОХО: Python
requests.get("https://api.internal/", verify=False)
urllib3.disable_warnings() # и заглушить предупреждение, чтобы не мешало
ctx = ssl._create_unverified_context()
// ПЛОХО: Go
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
// ПЛОХО: Node.js
const agent = new https.Agent({ rejectUnauthorized: false });
// и совсем плохо — глобально на весь процесс:
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
// ПЛОХО: .NET
var handler = new HttpClientHandler {
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
// ПЛОХО: Java — доверяем всему на свете
TrustManager[] trustAll = new TrustManager[]{ new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) {} // молча ок
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
}};
Почему это работает как уязвимость. Отключив проверку, вы оставляете от TLS только шифрование без аутентификации. Противник, оказавшийся на пути, — прозрачный прокси провайдера, подменённая DNS-запись, перехваченный BGP-анонс, соседний под с правом на raw-сокеты — предъявляет свой сертификат, ваш клиент его принимает, устанавливает с ним нормальное шифрованное соединение и отдаёт туда заголовок Authorization целиком. Дальше противник открывает соединение к настоящему серверу и проксирует трафик: обе стороны видят «всё работает». Это CWE-295, и от неё не спасают ни VPN, ни «внутренняя сеть».
Как чинить правильно. Проблема почти всегда одна: клиент не доверяет вашему CA. Решение — научить клиента доверять нужному корню, а не выключить проверку.
# ХОРОШО: доверяем конкретному корпоративному корню, проверку не трогаем
import requests, ssl
# вариант 1 — на уровне вызова
requests.get("https://api.internal/", verify="/etc/ssl/certs/corp-root.pem")
# вариант 2 — на весь процесс, без правки кода библиотек
# export REQUESTS_CA_BUNDLE=/etc/ssl/certs/corp-bundle.pem
# export SSL_CERT_FILE=/etc/ssl/certs/corp-bundle.pem
# вариант 3 — свой контекст: check_hostname и CERT_REQUIRED включены по умолчанию
ctx = ssl.create_default_context(cafile="/etc/ssl/certs/corp-root.pem")
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
assert ctx.check_hostname and ctx.verify_mode == ssl.CERT_REQUIRED
// ХОРОШО: свой пул корней, проверка имени и версии остаются на месте
pem, err := os.ReadFile("/etc/ssl/certs/corp-root.pem")
if err != nil {
return fmt.Errorf("не прочитан корневой сертификат: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return errors.New("корневой сертификат не разобран")
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: pool, // доверяем только этому корню
MinVersion: tls.VersionTLS12,
// InsecureSkipVerify НЕ выставляем — имя хоста проверяется автоматически
},
ForceAttemptHTTP2: true,
}
client := &http.Client{Transport: tr, Timeout: 10 * time.Second}
# Node.js: добавить корень процессу, не ломая проверку
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/corp-root.pem
# Java: положить корень в отдельный truststore, а не отключать TrustManager
keytool -importcert -alias corp-root -file corp-root.pem \
-keystore /opt/app/truststore.p12 -storetype PKCS12
# и запускать с -Djavax.net.ssl.trustStore=/opt/app/truststore.p12
Как проверить, что починено. Три уровня контроля.
# 1) Юнит-тест: клиент ОБЯЗАН отказаться от сертификата чужого CA.
# Стенд: локальный CA через mkcert/step-ca, сервер с сертификатом от "не нашего" корня.
import pytest, requests
def test_client_rejects_untrusted_ca(rogue_https_server):
with pytest.raises(requests.exceptions.SSLError):
api_client.get(rogue_https_server.url + "/health")
def test_client_rejects_wrong_hostname(server_with_cert_for_other_name):
# сертификат валиден, но выписан на другое имя — должен быть отказ
with pytest.raises(requests.exceptions.SSLError):
api_client.get(server_with_cert_for_other_name.url + "/health")
# 2) Гейт в CI: запрет опасных конструкций в коде.
# Semgrep-правила из реестра закрывают основные языки:
semgrep --config "p/secrets" --config "p/security-audit" --error .
# Быстрый и грубый вариант для собственного набора:
! grep -rnE 'InsecureSkipVerify\s*:\s*true|verify\s*=\s*False|rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED' \
--include='*.go' --include='*.py' --include='*.ts' --include='*.js' src/
# 3) Проверка живого стенда (только своего!): что реально согласуется
openssl s_client -connect api.example.com:443 -servername api.example.com \
-tls1_3 -status < /dev/null 2>&1 | head -40
testssl.sh --severity MEDIUM https://api.example.com/ # https://testssl.sh/
sslyze --certinfo --robot api.example.com # https://github.com/nabla-c0d3/sslyze
nmap --script ssl-enum-ciphers -p 443 api.example.com
Отдельно про исключения: если для отладки нужна возможность выключить проверку, она должна быть невозможна в продакшн-сборке. Флаг только под build tag debug (Go), только в debug-варианте (Android), с падением приложения при попытке включить его в релизе. Конфигурационный ключ tls_insecure: true, доступный в проде, рано или поздно будет включён «на пять минут» и останется навсегда.
Уязвимый код №2: своя валидация без проверки имени
Второй по частоте случай коварнее: проверка вроде бы есть, но она неполная.
// ПЛОХО: подменили всю штатную проверку своей "проверкой цепочки"
tls.Config{
InsecureSkipVerify: true, // без этого VerifyPeerCertificate НЕ заменяет проверку
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
cert, _ := x509.ParseCertificate(raw[0])
return cert.CheckSignatureFrom(ourCA) // подпись есть — и всё
},
}
// ПЛОХО: имя хоста не сверяется ни с чем
conn.setHostnameVerifier((hostname, session) -> true);
// ПЛОХО и незаметно: сырой SSLSocket в Java по умолчанию НЕ проверяет имя хоста
SSLSocket sock = (SSLSocket) factory.createSocket("api.example.com", 443);
sock.startHandshake(); // цепочка проверена, имя — нет
Почему это работает. Противнику достаточно любого сертификата, подписанного доверяемым вами CA, — например, выписанного на его собственный домен тем же публичным CA или тем же корпоративным корнем. Цепочка сходится, подпись верна, срок в порядке. Не совпадает только имя, а его никто не смотрит. Это CWE-297, и в корпоративной среде с внутренним CA она особенно опасна: любой сотрудник, имеющий право выпустить сертификат для своего сервиса, автоматически получает возможность встать посередине чужого.
Как чинить.
// ХОРОШО: если нужна дополнительная проверка — добавляем её ПОВЕРХ штатной
cfg := &tls.Config{
RootCAs: pool,
MinVersion: tls.VersionTLS12,
// InsecureSkipVerify не трогаем: стандартная валидация с проверкой имени работает.
VerifyPeerCertificate: func(_ [][]byte, chains [][]*x509.Certificate) error {
// сюда попадаем только после успешной штатной проверки
leaf := chains[0][0]
if !slices.Contains(allowedOUs, leaf.Subject.OrganizationalUnit[0]) {
return errors.New("сертификат выдан не тому подразделению")
}
return nil
},
}
// ХОРОШО: для сырых сокетов явно включаем идентификацию конечной точки
SSLParameters params = sock.getSSLParameters();
params.setEndpointIdentificationAlgorithm("HTTPS"); // без этой строки имя не сверяется
sock.setSSLParameters(params);
sock.startHandshake();
Как проверить. Тест test_client_rejects_wrong_hostname выше ловит ровно этот дефект: сертификат от доверенного CA, но на другое имя. Такой тест обязателен для любого места, где вы притрагивались к VerifyPeerCertificate, HostnameVerifier, checkServerIdentity или собственному TrustManager. И правило ревью: любой самописный код валидации сертификата — повод для отдельного обсуждения на ревью, наравне с самописной криптографией (подробнее в безопасной разработке).
Отзыв: проблема, которую решили сроком жизни
Ключ утёк — сертификат нужно аннулировать до истечения срока. Механизмы придумывали тридцать лет, и все они плохи:
- CRL (RFC 5280) — список отозванных серийников. Растёт до мегабайтов, обновляется с задержкой, тянуть его на каждое соединение немыслимо.
- OCSP (RFC 6960) — точечный запрос «жив ли этот сертификат». Две беды: приватность (CA видит, кто на какой сайт заходит) и латентность. Но главная — soft-fail: если респондер недоступен, клиенты продолжают соединение. Противник, способный встать посередине, легко «уроняет» и запрос к OCSP. Отсюда фраза Адама Лэнгли: OCSP с soft-fail — это ремень безопасности, который рвётся при аварии.
- OCSP stapling — сервер сам прикладывает свежий подписанный CA ответ к рукопожатию. Приватность решена, латентность решена, soft-fail остаётся: сервер противника просто ничего не приложит. Лечится расширением must-staple (RFC 7633), которое делает отсутствие ответа фатальным, — но тогда сбой респондера роняет ваш сайт.
- Списки на стороне браузера — CRLSets в Chrome, CRLite в Firefox: агрегированные и сжатые данные об отзывах, доставляемые вместе с обновлениями. Работает, но покрывает не всё и не мгновенно.
Индустрия пришла к простому выводу: надёжнее сокращать срок жизни, чем строить отзыв. Сертификат на 47 дней с полностью автоматическим перевыпуском делает отзыв редкой операцией, а не ежедневной. Показательный шаг: Let’s Encrypt объявил об отказе от OCSP и в 2025 году выключил свои OCSP-респондеры, оставив CRL, — приватность важнее механизма, который всё равно работал в режиме soft-fail.
Практический вывод для инженера: отзыв — не ваша основная линия обороны. Основная линия — короткий срок, автоматический перевыпуск и способность выкатить новый сертификат за минуты. Проверьте, что вы это умеете, до того как понадобится.
Обязательный минимум эксплуатации: перевыпуск в автоматике (ACME через certbot, lego, cert-manager в Kubernetes), мониторинг оставшегося срока с алертом за 21 день из внешней точки (проверять надо то, что реально отдаёт сервер, а не то, что лежит в файле), и отдельный алерт на «автоматика не отработала N раз подряд». Приватный ключ при перевыпуске всегда новый — переиспользование ключа сводит на нет смысл ротации; хранение ключей — тема управления секретами.
Certificate Transparency и CAA: защита от честной ошибки CA
Валидация цепочки доверяет любому корню из хранилища. Их сотни, они управляются разными организациями в разных юрисдикциях, и история знает случаи неправомерного выпуска — от взлома DigiNotar в 2011 году до систематических нарушений, за которые CA исключали из корневых программ. Два механизма позволяют это заметить.
Certificate Transparency (RFC 6962, обновление — RFC 9162): CA обязаны публиковать все выпущенные сертификаты в публичные append-only логи на структуре Меркла, а браузеры требуют доказательства публикации (SCT). Логи нельзя переписать задним числом. Практический смысл для вас — мониторинг собственных доменов: подпишитесь на уведомления через crt.sh, Cert Spotter или Facebook CT Monitor, и вы узнаете о любом выпущенном на ваш домен сертификате в течение часов. Это чисто оборонительная мера и абсолютно легальная: вы наблюдаете за своим именем в публичном журнале.
CAA (RFC 8659): DNS-запись, ограничивающая круг CA, которым разрешено выпускать сертификаты на ваш домен. CA обязаны её проверять перед выпуском.
# Разрешаем выпуск только одному CA и просим слать отчёты о нарушениях
example.com. IN CAA 0 issue "letsencrypt.org"
example.com. IN CAA 0 issuewild ";" # wildcard не выпускать никому
example.com. IN CAA 0 iodef "mailto:security@example.com"
# Привязка к конкретному аккаунту ACME — ещё уже:
example.com. IN CAA 0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/12345"
CAA не защищает от злонамеренного CA, но закрывает случайный выпуск и выпуск по подделанному подтверждению владения через другого поставщика. В связке с DNSSEC она заметно надёжнее. DANE/TLSA в вебе не прижилась, но в почте живёт и работает — если у вас SMTP, посмотрите на MTA-STS и DANE.
Веб-специфика: HSTS, cookie, смешанный контент
Даже идеальный TLS обходится, если пользователь приходит по http://. Классическая последовательность: пользователь набирает домен без схемы, браузер идёт по HTTP, противник посередине перехватывает и больше никогда не отдаёт редирект на HTTPS, работая с пользователем по HTTP, а с сервером — по HTTPS. Пользователь видит рабочий сайт без замочка.
Ответ — HSTS (RFC 6797): заголовок, после которого браузер сам переписывает http:// в https:// для этого домена и запрещает пользователю кликнуть «всё равно продолжить» на ошибке сертификата.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Что нужно знать до внедрения:
- Заголовок имеет смысл только на HTTPS-ответах; на HTTP его игнорируют (иначе противник посередине сам бы его выставил с
max-age=0). - HSTS работает по принципу trust-on-first-use: самый первый визит не защищён. Дыру закрывает preload-список, вшитый в браузеры (hstspreload.org).
preloadиincludeSubDomains— решение, которое трудно откатить: список обновляется вместе с браузерами, месяцами. Все поддомены, включая внутренние стенды и легаси на HTTP, обязаны уметь HTTPS. Внедряйте лесенкой:max-age=300→ неделя наблюдения →max-age=86400→includeSubDomains→ год →preload.- Cookie помечаем
Secureи по возможности используем префикс__Host-; подробности про cookie и SameSite — в статье про XSS и CSRF. - Смешанный контент: одна
<script src="http://...">на HTTPS-странице возвращает возможность внедрения кода. Браузеры блокируют активный смешанный контент сами, но найти его надо у себя:Content-Security-Policy: upgrade-insecure-requestsплюс отчёты черезreport-to. - Заголовок
Expect-CTустарел и удалён — CT сейчас проверяется браузерами по умолчанию, ставить его не нужно.
Где заканчивается ваш TLS
Наружный периметр обычно настроен прилично. Дальше начинается интересное: сколько раз трафик расшифровывается на пути от пользователя до базы данных и кто имеет доступ к этим точкам.
Три варианта поведения балансировщика стоит различать явно:
| Режим | Что происходит | Когда уместен |
|---|---|---|
| Passthrough | TLS доходит до пода, LB не расшифровывает | нужен end-to-end, LB не должен видеть содержимое |
| Re-encrypt | LB терминирует и открывает новое TLS-соединение к поду | нужен WAF/маршрутизация по пути + защита внутри |
| Termination | LB терминирует, дальше открытый текст | только если внутренний сегмент реально изолирован |
Модель «внутри периметра сеть доверенная» официально считается устаревшей — NIST SP 800-207 Zero Trust Architecture исходит из того, что расположение в сети не даёт никаких прав. В кластере открытый текст читают: под с CAP_NET_RAW на том же узле, сетевой плагин, инструменты трассировки, зеркалирование трафика, дампы при отладке. Отдельно про соединения к хранилищам — самый забываемый участок:
# PostgreSQL: sslmode=require шифрует, но НЕ проверяет собеседника — MITM проходит
postgresql://app@db.internal/orders?sslmode=verify-full&sslrootcert=/etc/ssl/corp-root.pem
# MySQL:
mysql --ssl-mode=VERIFY_IDENTITY --ssl-ca=/etc/ssl/corp-root.pem
# Kafka: security.protocol=SSL + ssl.endpoint.identification.algorithm=https
Значение sslmode=require в libpq — ровно тот случай «шифрование без аутентификации», с которого начиналась статья. И помните, что дефолт libpq — prefer, то есть «зашифруем, если получится, а нет так нет»; для продакшна это всегда явный verify-full.
mTLS: обе стороны предъявляют документы
Взаимный TLS — это обычный TLS, в котором сервер дополнительно просит сертификат у клиента.
Где mTLS оправдан. Трафик между сервисами внутри платформы; партнёрские B2B-API с ограниченным числом контрагентов; устройства и IoT, где нет пользователя, чтобы ввести пароль; административные плоскости (Kubernetes API, etcd, служебные порты БД). Главное преимущество перед общими секретами: нет разделяемого секрета, который можно украсть из логов или репозитория, и приватный ключ не покидает узла.
Где не оправдан. Массовый пользовательский веб: установка клиентских сертификатов в браузеры — операционный ад, ротация и восстановление после потери устройства ломают UX сильнее любого MFA.
Главная ошибка внедрения — считать mTLS авторизацией. Проверив цепочку, вы узнали только, что сертификат выдан вашим CA. Если ваш CA выдаёт сертификаты всем сервисам платформы, то сертификат сервиса «рассылка отчётов» откроет доступ и к платёжному API — если тот проверяет только факт валидности. Аутентификация должна заканчиваться извлечением идентичности и явной сверкой со списком разрешённых.
// Сервер: требуем клиентский сертификат от нашего приватного CA
// и ОТДЕЛЬНО проверяем, кто именно пришёл.
tlsCfg := &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert, // не VerifyClientCertIfGiven!
ClientCAs: privateRootPool, // только наш корень, не системный
MinVersion: tls.VersionTLS13,
}
func identityFrom(r *http.Request) (string, error) {
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
return "", errors.New("клиентский сертификат отсутствует")
}
leaf := r.TLS.PeerCertificates[0]
// SPIFFE-идентичность лежит в URI SAN: spiffe://prod/ns/billing/sa/payments
for _, u := range leaf.URIs {
if u.Scheme == "spiffe" {
return u.String(), nil
}
}
return "", errors.New("в сертификате нет SPIFFE ID")
}
func handler(w http.ResponseWriter, r *http.Request) {
id, err := identityFrom(r)
if err != nil {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Авторизация — отдельный, явный шаг. Список, а не "любой валидный".
if !allowedCallers[id] {
log.Warn("отказ вызывающему", "spiffe_id", id, "route", r.URL.Path)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// ... обработка
}
Обратите внимание на RequireAndVerifyClientCert: режим VerifyClientCertIfGiven проверяет сертификат, только если клиент его прислал, и клиент без сертификата спокойно проходит. Это популярный способ случайно оставить дверь открытой. Аналогичная пара в nginx — ssl_verify_client on против optional; и если используете $ssl_client_verify, проверяйте, что значение ровно SUCCESS.
PKI под mTLS. Публичные CA здесь не нужны и вредны: вам нужен свой корень, промежуточные по средам, короткие сроки жизни и автоматика. Индустриальный стандарт — SPIFFE/SPIRE (CNCF): узлы аттестуются платформой (метка узла, ServiceAccount в Kubernetes, инстанс в облаке), получают X.509-SVID с идентичностью в URI SAN и временем жизни в часы, ключи не покидают процесс, ротация непрерывна. Тогда отзыв снова не нужен: скомпрометированная идентичность истекает сама. Готовые реализации поверх — Istio, Linkerd, Consul Connect; в Kubernetes сертификаты выпускает cert-manager, а идентичность узлов даёт проекция ServiceAccount-токенов.
Связка с токенами. mTLS умеет усиливать OAuth: RFC 8705 описывает привязку access-токена к клиентскому сертификату (подтверждение cnf.x5t#S256). Украденный токен становится бесполезен без приватного ключа — из bearer-токена он превращается в токен-владения. Подробнее про это и про DPoP — в статьях о OAuth и JWT.
Пиннинг: сильное лекарство с тяжёлыми побочками
Пиннинг сужает множество принимаемых ключей: клиент соглашается не на «любой сертификат от любого из сотен корней», а только на перечисленные заранее. Это единственная защита от неправомерного выпуска настоящим CA — и одновременно самый быстрый способ вывести приложение из строя.
Что именно пиннить. Не сам сертификат, а хеш SubjectPublicKeyInfo (SPKI). Тогда перевыпуск сертификата на тот же ключ не ломает пины, а список остаётся стабильным при плановой ротации сертификатов.
# Вычисление SPKI-пина для СВОЕГО сертификата
openssl x509 -in leaf.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
Что показал опыт HPKP. Заголовок HTTP Public Key Pinning (RFC 7469) позволял сайту закрепить пины в браузере. Механизм признали вредным и удалили из Chrome: ошибка в пинах превращала сайт в недоступный на весь срок max-age без возможности исправить, а злоумышленник, получивший временный контроль над сайтом, мог закрепить свои пины и сделать домен недоступным надолго («RansomPKI»). Вывод: пиннинг применим там, где вы контролируете и клиент, и сервер, и цикл обновления клиента — то есть в мобильных и десктопных приложениях, встроенных устройствах, внутренних клиентах.
Правила, без которых пиннинг превращается в аварию.
- Минимум два пина: текущий ключ и запасной, чей приватный ключ хранится офлайн или в HSM и никогда не был на боевом сервере. Без backup-пина потеря ключа означает мёртвое приложение до выхода обновления через магазин приложений.
- Пин переживает сертификат, но не переживает смену ключа — планируйте ротацию ключей как релизный процесс, а не как операционную мелочь.
- Срок годности пинов короче, чем срок жизни версии приложения в дикой природе. Пользователи не обновляются мгновенно; после «протухания» пинов должен оставаться штатный fallback на системное хранилище, а не кирпич.
- Kill-switch. Возможность удалённо (по подписанному конфигу, через отдельный канал) отключить пиннинг — обязательна.
- Пиннинг ломает корпоративные MITM-прокси. Для B2B-приложений это может оказаться блокером внедрения; решение — пиннить только критичные эндпоинты либо разрешать пользовательские корни из хранилища устройства.
- Пиннинг мешает вашему же QA. Исключения для отладочных прокси делаются только в debug-сборках, никогда в релизных.
На Android это решается декларативно, без единой строчки кода и без риска ошибиться в реализации проверки:
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<!-- Открытый HTTP запрещён на весь app по умолчанию -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</base-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<!-- expiration обязателен: после этой даты пиннинг перестаёт действовать,
приложение продолжает работать по системному хранилищу -->
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">ТЕКУЩИЙ_SPKI_ХЕШ_В_BASE64=</pin>
<pin digest="SHA-256">РЕЗЕРВНЫЙ_SPKI_ХЕШ_В_BASE64=</pin>
</pin-set>
</domain-config>
<!-- Пользовательские CA доверяем ТОЛЬКО в отладочной сборке -->
<debug-overrides>
<trust-anchors>
<certificates src="user"/>
</trust-anchors>
</debug-overrides>
</network-security-config>
На iOS App Transport Security задаёт базовые требования к TLS, а пиннинг делается либо через NSPinnedDomains в Info.plist, либо в делегате URLSession — но реализовывать проверку вручную стоит только при крайней необходимости: это ровно тот самописный код валидации, который чаще всего пишут с ошибкой. Ориентир по требованиям и проверкам — OWASP MASVS/MASTG, раздел MASVS-NETWORK, и OWASP Pinning Cheat Sheet.
И честное предупреждение: пиннинг не делает приложение неломаемым. На устройстве, где противник контролирует ОС (root/jailbreak, инструментация рантайма), пиннинг обходится — это защита канала от сети, а не защита приложения от владельца устройства. Проверять обход пиннинга можно только на своих сборках и своих тестовых устройствах, в рамках авторизованного тестирования.
Как проверить, что всё это работает
Сводная процедура для собственных систем — от быстрой проверки до постоянного контроля:
# 1) Что реально согласуется и какая цепочка отдаётся (свой домен!)
openssl s_client -connect api.example.com:443 -servername api.example.com \
-showcerts < /dev/null 2>/dev/null | openssl x509 -noout -text | \
grep -A2 'Subject Alternative Name\|Not After\|Signature Algorithm'
# 2) Отдаёт ли сервер промежуточные сертификаты (частая причина "работает только в браузере")
openssl s_client -connect api.example.com:443 -servername api.example.com \
-verify_return_error < /dev/null
# 3) Полный аудит конфигурации
testssl.sh --full --severity LOW https://api.example.com/
# 4) Проверка, что старые версии действительно отключены — должно быть отказано
openssl s_client -connect api.example.com:443 -tls1_1 < /dev/null # ожидаем ошибку
# 5) Мониторинг срока: скрипт в cron/CI, алерт за 21 день
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
| openssl x509 -noout -checkend $((21*24*3600)) || echo "СЕРТИФИКАТ ИСТЕКАЕТ"
Что нужно закрыть автотестами, а не ручной проверкой:
- клиент отказывает при сертификате от недоверенного CA;
- клиент отказывает при валидном сертификате с чужим именем хоста;
- клиент отказывает при просроченном сертификате (стенд с искусственно сдвинутым сроком);
- сервер отказывает клиенту без сертификата, если объявлен mTLS;
- сервер отказывает клиенту с валидным сертификатом, но не входящим в allowlist идентичностей;
- HTTP-эндпоинт редиректит на HTTPS и отдаёт HSTS только на HTTPS-ответе;
- подключение к БД без
verify-fullне поднимается.
И постоянный контроль в эксплуатации: мониторинг CT-логов по своим доменам, дашборд распределения версий TLS у реальных клиентов (перед отключением старой версии нужно знать цену), алерты на приближение срока и на сбои ACME, регулярный автоматический скан своих эндпоинтов в CI. Метрики TLS-рукопожатий и ошибок валидации стоит завести в общей телеметрии — см. наблюдаемость.
Чек-лист типичных ошибок
| Ошибка | Чем грозит | Как чинить |
|---|---|---|
verify=False / InsecureSkipVerify / rejectUnauthorized:false |
полный MITM, утечка токенов | добавить нужный корень в доверие, запретить конструкцию в CI |
| Своя валидация без проверки имени хоста | MITM с любым сертификатом от того же CA | штатная проверка + свои правила поверх неё |
Сырой SSLSocket в Java |
имя хоста не сверяется | setEndpointIdentificationAlgorithm("HTTPS") |
sslmode=require к PostgreSQL |
шифрование без аутентификации | verify-full + sslrootcert |
| Терминация TLS на LB и открытый текст внутри | чтение трафика изнутри кластера | re-encrypt или mTLS между сервисами |
VerifyClientCertIfGiven / ssl_verify_client optional |
клиент без сертификата проходит | RequireAndVerifyClientCert / on |
| mTLS без сверки идентичности | любой сервис платформы вызывает любой | allowlist SPIFFE ID или SAN |
Системный пул корней как ClientCAs |
клиентом может стать кто угодно с публичным сертификатом | только приватный корень |
| Нет промежуточных в цепочке | «работает в браузере, падает в curl и на Android» | настроить полную цепочку, проверить -verify_return_error |
| Ручной выпуск сертификатов | просрочка и авария в самый неудобный момент | ACME + мониторинг из внешней точки |
HSTS без плана и сразу с preload |
недоступные поддомены, откат месяцами | лесенка max-age, инвентаризация поддоменов |
| 0-RTT для изменяющих состояние запросов | повтор запроса противником | 0-RTT только для идемпотентных |
| Статичные ключи session tickets | потеря прямой секретности | ротация ключей тикетов или их отключение |
| Пиннинг без backup-пина и kill-switch | приложение-кирпич | два пина, срок годности, удалённое отключение |
| Wildcard-сертификат на всё | один ключ — весь поддомен | отдельные сертификаты через автоматику |
Мини-итог
TLS решает ровно одну задачу: превращает враждебный канал в защищённый канал к правильному собеседнику. Слова «к правильному собеседнику» обеспечиваются не криптографией, а цепочкой сертификатов, проверкой имени хоста и вашим решением, каким корням верить, — и именно эти три вещи чаще всего ломают в коде одной строкой.
Что стоит запомнить:
- TLS 1.3 разумен по умолчанию: эфемерные ключи всегда, только AEAD, защита от отката встроена в
Finished. Ваша работа — не мешать: не фиксировать древние списки, не выключать проверки, не открывать 0-RTT для неидемпотентных запросов. - Сертификат — утверждение о принадлежности ключа именам; имена только в SAN, доказательство владения — в
CertificateVerify, а не в самом сертификате. - Отзыв ненадёжен по своей природе. Настоящий механизм — короткий срок жизни и автоматический перевыпуск. Умение выкатить новый сертификат за минуты ценнее любого OCSP.
- CT и CAA — оборонительные инструменты против неправомерного выпуска; мониторить свои домены в CT-логах нужно всем, это бесплатно.
- Внутри периметра тоже сеть. Считайте открытым текстом всё, что проходит через узел с вашим приватным ключом, и шифруйте участки между сервисами и до хранилищ.
- mTLS — про «кто это», не про «что можно». Идентичность обязана явно сверяться со списком разрешённых.
- Пиннинг — точечный инструмент для случаев, когда вы владеете клиентом, сервером и циклом обновления, и всегда с backup-пином, сроком годности и планом отката.
Источники
- RFC 8446 — TLS 1.3; RFC 5246 — TLS 1.2; RFC 9001 — TLS в QUIC
- RFC 9325 (BCP 195) — рекомендации по безопасному использованию TLS; RFC 8996 — отказ от TLS 1.0/1.1
- RFC 5280 — профиль X.509 и CRL; RFC 9525 — проверка идентичности сервиса в TLS
- RFC 6960 — OCSP; RFC 7633 — must-staple; RFC 6962 и RFC 9162 — Certificate Transparency
- RFC 6797 — HSTS; RFC 8659 — CAA; RFC 8705 — mTLS и привязка токенов к сертификату
- NIST SP 800-52 Rev. 2 — рекомендации по внедрению TLS; NIST SP 800-207 — Zero Trust Architecture
- OWASP Transport Layer Security Cheat Sheet, Pinning Cheat Sheet, OWASP MASVS/MASTG
- CWE: CWE-295, CWE-296, CWE-297, CWE-319, CWE-326, CWE-757
- CA/Browser Forum Baseline Requirements — правила выпуска публичных сертификатов
- Georgiev et al., «The Most Dangerous Code in the World», CCS 2012 — почему клиенты не проверяли сертификаты
- Ivan Ristić, «Bulletproof TLS and PKI», 2nd edition — самая полная практическая книга по теме
- Mozilla SSL Configuration Generator, testssl.sh, SSLyze, mkcert, SPIFFE
Что дальше
Канал защищён, собеседник проверен, ключи ротируются. Но всё, что приезжает по этому каналу, по-прежнему приходит от кого-то, кто может ошибаться или лгать: аутентифицированный клиент способен запросить чужой заказ по угаданному идентификатору, прислать поле is_admin в теле JSON, перебрать коды подтверждения тысячей запросов в секунду или отправить структуру, которую ваш парсер развернёт в гигабайт памяти. Следующая статья — про защиту API на уровне запросов: ограничение частоты, IDOR, массовое присвоение и валидацию входных данных.
Безопасность API: rate limiting, IDOR, массовое присвоение, валидация