DarkRiDDeR10 мин

TLS в PHP. Почему CA bundle, имя хоста и SNI проверяются по-разному

PHPTLSOpenSSLРазбор

PHP cURL может получить сертификат и всё равно остановить запрос. Ошибка становится особенно дорогой, когда её принимают за одну настройку и выключают verification: в реальности у клиента могут не совпасть цепочка, имя хоста или сертификат, выбранный сервером по SNI.

Давайте разложим механизм на три части. Это не теория ради теории: после такого разделения понятно, какую команду запускать и кому отдавать исправление — разработчику PHP, администратору окружения или владельцу HTTPS-сервера. Цена ошибки — отключить проверку TLS и не заметить подмену сертификата.

У HTTPS-соединения несколько условий

TLS даёт шифрование канала, но клиенту ещё нужно принять решение о личности удалённой стороны. В связке cURL/OpenSSL для обычного HTTPS запроса важны как минимум две независимые проверки: можно ли построить доверенную цепочку до локального CA store и подходит ли имя в сертификате тому hostname, который стоит в URL.

SNI относится к другому месту. Это расширение ClientHello: клиент сообщает серверу ожидаемое имя до выдачи сертификата. На одном IP-адресе могут жить несколько HTTPS сайтов. Если серверу не дать имя, он вправе выбрать сертификат виртуального хоста по умолчанию. После этого проверка цепочки может быть безупречной, но проверка имени правильного сайта всё равно не пройдёт.

Схема TLS-проверки: URL задаёт имя, SNI помогает серверу выбрать сертификат, сервер передаёт leaf и промежуточные сертификаты, а клиент соединяет их с доверенным корнем из локального CA bundle и отдельно сверяет hostname.
Сервер выбирает сертификат по SNI, а клиент затем проверяет две разные вещи: доверенную цепочку и имя из URL.
ЧастьКто её задаётЧто проверяет клиентТипичная граница ошибки
Hostname в URLКод PHPЧто имя покрыто сертификатомВ URL IP или другое имя
SNI в ClientHelloTLS-клиент при соединении по имениКакой виртуальный хост ответилСервер отдал сертификат default-vhost
Leaf и intermediateHTTPS-серверМожно ли дойти от leaf до trust anchorСервер не прислал intermediate
CA bundle / CApathОкружение клиентаКакой корень считается довереннымНужного корня нет или файл не читается

Что сервер присылает, а что хранит клиент

Сервер обычно отправляет конечный сертификат сайта и промежуточные сертификаты. Корневой сертификат чаще остаётся в локальном наборе доверия клиента. OpenSSL в документации к s_client отдельно предупреждает: -showcerts показывает именно список, присланный сервером, а не уже проверенную цепочку.

Это различие удобно держать в голове при ошибке unable to get local issuer certificate. Она может означать, что сервер не выдал промежуточный сертификат. Может означать, что корень есть у браузера, но отсутствует в bundle процесса PHP. А может означать, что мы подключились к другому виртуальному хосту и смотрим на чужую цепочку. Одна строка без контекста не выбирает причину.

Снимаю серверный список с правильным SNI

В OpenSSL 1.0.2 параметр -servername явно задаёт TLS Server Name Indication. Для диагностики я использую hostname из URL приложения и не подставляю IP вместо него. Сохранённый вывод нужен для ручного просмотра subject и issuer; в статью и тикет не нужно копировать приватные заголовки или ключи.

openssl s_client \
  -connect api.partner.example:443 \
  -servername api.partner.example \
  -showcerts \
  </dev/null

# В выводе выписываем PEM-блок leaf и каждый intermediate.
# Корневой CA обычно ищем в локальном bundle, а не в ответе сервера.

Полезно выполнить команду второй раз без -servername только как сравнение выбора виртуального хоста. Разные сертификаты не доказывают ошибку сами по себе, но объясняют, почему проверка по IP или старый тест без SNI ведут не к тому сайту. Исправление тогда находится в hostname запроса, DNS или настройке TLS-виртуального хоста, а не в бессмысленном добавлении чужого сертификата в CAfile.

Проверяю цепочку отдельно от HTTP

После того как PEM-блоки разделены вручную, OpenSSL умеет проверить цепочку без HTTP-кода и заголовков. В этой команде конечный сертификат лежит в leaf.pem, присланный сервером intermediate — в intermediate.pem, а доверенные корни — в нашем проверяемом ca-bundle.pem.

openssl verify \
  -purpose sslserver \
  -CAfile ./ca-bundle.pem \
  -untrusted ./intermediate.pem \
  ./leaf.pem

Параметры здесь не взаимозаменяемы. В документации OpenSSL -CAfile — файл доверенных сертификатов, а -untrusted — дополнительные сертификаты для построения цепочки. Не стоит переносить промежуточный сертификат в доверенные корни только для того, чтобы команда стала зелёной. Такое смешение скрывает, кто именно должен поставлять intermediate.

Имя хоста проверяется отдельно

Даже успешный openssl verify не отвечает на вопрос, подходит ли сертификат адресу api.partner.example. Команда проверяет цепочку. cURL делает имя отдельным условием: CURLOPT_SSL_VERIFYHOST проверяет, что имя в сертификате допустимо для hostname, к которому выполняется соединение. Поэтому тестируем тот же URL, который использует приложение.

$ch = curl_init('https://api.partner.example/v1/ping');
curl_setopt_array($ch, array(
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CAINFO => '/opt/app/certs/ca-bundle.pem',
    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,
));

$body = curl_exec($ch);
if ($body === false) {
    throw new RuntimeException(curl_errno($ch) . ': ' . curl_error($ch));
}
curl_close($ch);

Не заменяю URL на IP и не рассчитываю, что HTTP-заголовок Host исправит TLS-идентичность. Сертификат обычно выдан на DNS-имя; IP подходит только если он действительно указан в сертификате как IP-адрес. Внутренний DNS, прокси и тестовый маршрут должны сохранить имя, которое читает cURL до отправки HTTP.

Матрица неисправностей

Симптом после проверкиНаиболее узкая гипотезаПроверкаКуда идёт исправление
s_client с SNI показывает ожидаемый leaf, но verify не строит цепочкуНет intermediate в ответе или нужного корня в CA bundleРазделить PEM и запустить openssl verifyСервер или владелец trust store
Без SNI и с SNI разные leafВыбирается другой TLS virtual hostСравнить два запуска s_clientURL/DNS либо TLS-конфигурация сервера
Цепочка проходит, PHP отказывает на имениHostname URL не покрыт SAN/CN сертификатаПроверить точное имя URL и сертификатаКод/настройка адреса или перевыпуск сертификата
CLI доверяет, PHP нетРазные libcurl, TLS backend или CAfileСобрать curl_version() и путь bundlePHP-окружение

Порядок, который не смешивает причины

  1. Взять hostname прямо из конфигурации PHP-запроса и зафиксировать версию libcurl/TLS через curl_version().
  2. Получить серверные сертификаты через openssl s_client с этим hostname в -servername.
  3. Разделить leaf, intermediate и локальный CA bundle; не объявлять каждый присланный сертификат доверенным.
  4. Запустить openssl verify, чтобы отделить цепочку от HTTP и от проверки имени.
  5. Проверить тот же URL PHP-кодом при включённых CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST.
  6. Передать владельцу нужную ветку: недостающий intermediate, обновление CA store, неверное имя либо TLS virtual host.

Ограничения этого разбора

  • Формат, порядок и текст ошибок могут отличаться между версиями OpenSSL и libcurl. Ценны не скопированные строки, а сохранённые команды, hostname и версии.
  • Проверка цепочки не заменяет проверки срока действия, политики организации или отзыва сертификата, если эти условия включены в конкретном окружении.
  • Частный корпоративный CA нельзя добавлять в bundle по письму без подтверждения владельца. Доверенный корень даёт право выпускать сертификаты для той области, где ему доверяет клиент.
  • Устаревший OpenSSL может иметь отдельные ограничения протоколов и шифров. Эта статья не советует включать старый протокол для обхода ошибки цепочки.

Итог

CA bundle отвечает на вопрос, кому клиент доверяет. Серверная цепочка отвечает, может ли leaf дойти до этого доверия. SNI помогает серверу выбрать правильный leaf, а hostname check подтверждает, что он выдан нужному имени. Когда эти четыре роли разложены, ошибка TLS перестаёт быть поводом ставить false и становится обычной диагностической задачей.

Проверяемые источники

  • curl: SSL CA Certificates — проверка сертификата включена по умолчанию; CA store можно передать для конкретного соединения
  • libcurl: CURLOPT_SSL_VERIFYPEER — выключение проверки не загружает CA и делает TLS-соединение небезопасным
  • libcurl: CURLOPT_SSL_VERIFYHOST — проверка имени хоста в сертификате является отдельным условием
  • OpenSSL 1.0.2: s_client — диагностика TLS-сервера, параметры -servername, -showcerts, -CAfile и -CApath
  • OpenSSL 1.0.2: verify — проверка цепочки, различие trusted CAfile и untrusted промежуточных сертификатов
  • PHP Manual: cURL constants — значения CURLOPT_CAINFO, CURLOPT_CAPATH, CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST
  • PHP Manual: curl_version — версия libcurl и TLS-библиотеки, с которыми собран PHP-модуль