PHP cURL может получить сертификат и всё равно остановить запрос. Ошибка становится особенно дорогой, когда её принимают за одну настройку и выключают verification: в реальности у клиента могут не совпасть цепочка, имя хоста или сертификат, выбранный сервером по SNI.
Давайте разложим механизм на три части. Это не теория ради теории: после такого разделения понятно, какую команду запускать и кому отдавать исправление — разработчику PHP, администратору окружения или владельцу HTTPS-сервера. Цена ошибки — отключить проверку TLS и не заметить подмену сертификата.
У HTTPS-соединения несколько условий
TLS даёт шифрование канала, но клиенту ещё нужно принять решение о личности удалённой стороны. В связке cURL/OpenSSL для обычного HTTPS запроса важны как минимум две независимые проверки: можно ли построить доверенную цепочку до локального CA store и подходит ли имя в сертификате тому hostname, который стоит в URL.
SNI относится к другому месту. Это расширение ClientHello: клиент сообщает серверу ожидаемое имя до выдачи сертификата. На одном IP-адресе могут жить несколько HTTPS сайтов. Если серверу не дать имя, он вправе выбрать сертификат виртуального хоста по умолчанию. После этого проверка цепочки может быть безупречной, но проверка имени правильного сайта всё равно не пройдёт.
| Часть | Кто её задаёт | Что проверяет клиент | Типичная граница ошибки |
|---|---|---|---|
| Hostname в URL | Код PHP | Что имя покрыто сертификатом | В URL IP или другое имя |
| SNI в ClientHello | TLS-клиент при соединении по имени | Какой виртуальный хост ответил | Сервер отдал сертификат default-vhost |
| Leaf и intermediate | HTTPS-сервер | Можно ли дойти от 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_client | URL/DNS либо TLS-конфигурация сервера |
| Цепочка проходит, PHP отказывает на имени | Hostname URL не покрыт SAN/CN сертификата | Проверить точное имя URL и сертификата | Код/настройка адреса или перевыпуск сертификата |
| CLI доверяет, PHP нет | Разные libcurl, TLS backend или CAfile | Собрать curl_version() и путь bundle | PHP-окружение |
Порядок, который не смешивает причины
- Взять hostname прямо из конфигурации PHP-запроса и зафиксировать версию libcurl/TLS через
curl_version(). - Получить серверные сертификаты через
openssl s_clientс этим hostname в-servername. - Разделить leaf, intermediate и локальный CA bundle; не объявлять каждый присланный сертификат доверенным.
- Запустить
openssl verify, чтобы отделить цепочку от HTTP и от проверки имени. - Проверить тот же URL PHP-кодом при включённых
CURLOPT_SSL_VERIFYPEERиCURLOPT_SSL_VERIFYHOST. - Передать владельцу нужную ветку: недостающий 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-модуль