Безопасность приложений Транспортная безопасность: TLS, сертификаты, mTLS, пиннинг
0%

Транспортная безопасность: TLS, сертификаты, mTLS, пиннинг

Транспортная безопасность: 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.1RFC 8996, формально устарели, тянут за собой CBC и MD5/SHA-1.
  • RC4RFC 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.

Анатомия сертификата X.509 и цепочка доверия

Ключевые нюансы, из которых растут ошибки:

Имена берутся только из 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, а строгий клиент — нет.

Алгоритм проверки: что клиент обязан сделать

Два шага из этой схемы обеспечивают почти всю статистику уязвимостей.

Первый — проверка имени хоста. Её нет в 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.

Даже идеальный 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=86400includeSubDomains → год → preload.
  • Cookie помечаем Secure и по возможности используем префикс __Host-; подробности про cookie и SameSite — в статье про XSS и CSRF.
  • Смешанный контент: одна <script src="http://..."> на HTTPS-странице возвращает возможность внедрения кода. Браузеры блокируют активный смешанный контент сами, но найти его надо у себя: Content-Security-Policy: upgrade-insecure-requests плюс отчёты через report-to.
  • Заголовок Expect-CT устарел и удалён — CT сейчас проверяется браузерами по умолчанию, ставить его не нужно.

Где заканчивается ваш TLS

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

Точки терминации 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»). Вывод: пиннинг применим там, где вы контролируете и клиент, и сервер, и цикл обновления клиента — то есть в мобильных и десктопных приложениях, встроенных устройствах, внутренних клиентах.

Правила, без которых пиннинг превращается в аварию.

  1. Минимум два пина: текущий ключ и запасной, чей приватный ключ хранится офлайн или в HSM и никогда не был на боевом сервере. Без backup-пина потеря ключа означает мёртвое приложение до выхода обновления через магазин приложений.
  2. Пин переживает сертификат, но не переживает смену ключа — планируйте ротацию ключей как релизный процесс, а не как операционную мелочь.
  3. Срок годности пинов короче, чем срок жизни версии приложения в дикой природе. Пользователи не обновляются мгновенно; после «протухания» пинов должен оставаться штатный fallback на системное хранилище, а не кирпич.
  4. Kill-switch. Возможность удалённо (по подписанному конфигу, через отдельный канал) отключить пиннинг — обязательна.
  5. Пиннинг ломает корпоративные MITM-прокси. Для B2B-приложений это может оказаться блокером внедрения; решение — пиннить только критичные эндпоинты либо разрешать пользовательские корни из хранилища устройства.
  6. Пиннинг мешает вашему же 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-пином, сроком годности и планом отката.

Источники

Что дальше

Канал защищён, собеседник проверен, ключи ротируются. Но всё, что приезжает по этому каналу, по-прежнему приходит от кого-то, кто может ошибаться или лгать: аутентифицированный клиент способен запросить чужой заказ по угаданному идентификатору, прислать поле is_admin в теле JSON, перебрать коды подтверждения тысячей запросов в секунду или отправить структуру, которую ваш парсер развернёт в гигабайт памяти. Следующая статья — про защиту API на уровне запросов: ограничение частоты, IDOR, массовое присвоение и валидацию входных данных.

Безопасность API: rate limiting, IDOR, массовое присвоение, валидация

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

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

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

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