{ "index": 338, "slug": "editorial-2018-08-mechanism-tls-ca", "title": "TLS в PHP cURL: как отличить CA bundle, hostname и SNI", "excerpt": "PHP cURL может отклонить HTTPS-соединение даже тогда, когда сайт открывается в браузере. Разбираем цепочку сертификатов, имя хоста и SNI, а затем проверяем каждую причину отдельно.", "contentHtml": "

PHP cURL получает сертификат от HTTPS-сервера, но останавливает запрос с ошибкой проверки. В браузере тот же адрес открывается. Команда пробует добавить повтор, заменить имя на IP или поставить CURLOPT_SSL_VERIFYPEER => false. Запрос начинает проходить, но клиент больше не подтверждает личность сервера. Цена ошибки — отправить токен, персональные данные или платёжный запрос не тому узлу.

У такого отказа нет одной универсальной причины. Клиент строит цепочку до доверенного корня, проверяет имя в сертификате и получает сертификат для нужного виртуального хоста. Эти проверки связаны в одном TLS-сеансе, но принадлежат разным сторонам. Если разделить их, диагностика превращается из перебора настроек в короткий набор проверок.

Главный тезис: TLS-проверка состоит из отдельных условий

CA bundle отвечает на вопрос «каким центрам сертификации доверяет этот процесс». Серверная цепочка отвечает на вопрос «можно ли от конечного сертификата дойти до такого центра». Проверка hostname отвечает на вопрос «выдан ли сертификат имени из URL». SNI помогает серверу выбрать сертификат до того, как клиент увидит ответ.

Браузер и PHP могут использовать разные TLS-библиотеки, хранилища и прокси. Поэтому результат в браузере не доказывает, что PHP видит тот же bundle и тот же виртуальный хост. Сначала фиксируйте URL и окружение процесса, затем проверяйте серверный ответ и локальное доверие.

\"Схема
Учебная схема: SNI влияет на выбор сертификата сервером, а CA bundle и hostname проверяются клиентом.

Что происходит между URL и HTTP

cURL сначала разбирает URL. Из него он получает имя, порт и путь. Для https://api.example.test/v1/ping hostname — api.example.test. Это имя участвует в TLS-переговорах. Заголовок HTTP Host появляется позже. Он не исправляет сертификат, который уже был выбран и проверен на TLS-уровне.

В ClientHello TLS-клиент обычно передаёт SNI с тем же именем. Сервер использует SNI, чтобы выбрать конфигурацию виртуального хоста. На одном IP могут жить десятки сайтов. Если имя не передано или передано неверно, сервер может вернуть сертификат default-vhost. Этот сертификат может иметь корректную подпись, но не покрывать имя из URL.

После ответа сервера клиент получает конечный сертификат сайта и, как правило, промежуточные сертификаты. Корневой сертификат обычно уже находится в локальном хранилище. Клиент строит путь от leaf через intermediate к доверенному корню. Если intermediate не прислан, а локальный bundle не содержит его как доверенный якорь, путь может не построиться.

ЧастьКто отвечаетЧто проверяетсяТипичный сбой
Hostname в URLКод и конфигурация PHPИмя покрыто SAN сертификатаВ URL указан IP или чужое имя
SNITLS-клиент и серверный vhostВыбран сертификат нужного сайтаОтдан default-vhost
Leaf и intermediateHTTPS-серверСтроится путь сертификатовНе прислан intermediate
CA bundleОкружение PHPКорень считается довереннымФайл отсутствует или недоступен

Сначала снимите наблюдаемый ответ сервера

Для диагностики нужен hostname из настоящего URL приложения. Не подставляйте IP, если хотите проверить рабочий маршрут. Команда ниже — учебный пример с вымышленным доменом. Она не доказывает состояние какого-либо production-сервера, а показывает способ получить список сертификатов с заданным SNI.

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

В выводе найдите конечный сертификат и промежуточные сертификаты. -showcerts показывает сертификаты, присланные сервером. Он не означает, что OpenSSL уже построил доверенную цепочку. Сохраните версию OpenSSL, hostname и саму команду рядом с результатом. Не публикуйте приватные ключи и секретные заголовки из диагностического окружения.

Затем выполните тот же учебный запрос без SNI только как контрольное сравнение:

openssl s_client -connect api.example.test:443 -noservername -showcerts < /dev/null

Если leaf различается, сервер выбирает разные виртуальные хосты. Это не повод добавлять полученный сертификат в CA bundle. Проверьте DNS, URL, балансировщик и конфигурацию vhost. Если сертификаты одинаковы, переходите к цепочке и локальному trust store.

Проверьте цепочку без PHP и HTTP

Разделите сохранённый ответ на leaf.pem и intermediate.pem. Доверенный bundle храните отдельно в ca-bundle.pem. В учебной команде -CAfile задаёт доверенные корни, а -untrusted добавляет сертификаты для построения пути. Intermediate не становится доверенным только потому, что его прислал сервер.

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

Успех этой команды означает, что для этих файлов OpenSSL построил допустимую цепочку. Он не подтверждает hostname рабочего URL и не проверяет, что PHP загрузил именно этот файл. Ошибка «unable to get local issuer certificate» может означать неполный ответ сервера, отсутствующий корень или другой bundle. Одна строка ошибки не выбирает ветку сама.

Проверьте срок действия, назначение сертификата и имя в его SAN. Не переносите intermediate в список корней ради зелёного результата. Если сервер не отправляет нужный intermediate, исправление принадлежит владельцу HTTPS-сервера. Если частный корень нужен внутреннему сервису, его распространяет владелец инфраструктуры через управляемый пакет или секрет.

Проверьте тот же URL в PHP cURL

В PHP явно задайте путь к bundle только для учебного примера. В рабочей системе путь должен приходить из конфигурации или управляемого окружения. Проверка peer и hostname должна оставаться включённой.

<?php $ch = curl_init('https://api.example.test/v1/ping'); curl_setopt_array($ch, [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);

Если CLI cURL проходит, а PHP нет, сравните curl_version(), TLS backend, путь CAfile, права чтения и пользователя процесса. Браузер мог использовать системное хранилище, а PHP — файл из сборки libcurl или значение curl.cainfo. Проверяйте фактический процесс, а не только интерактивную shell-сессию.

Симптом → причина → проверка → действие

СимптомПричинаПроверкаДействие
С SNI приходит ожидаемый leaf, но verify не строит путьНет intermediate или корня в bundleРазделить PEM и запустить verifyИсправить серверную цепочку или CA store
Leaf меняется при отключении SNIВыбран другой TLS virtual hostСравнить два запуска s_clientИсправить hostname, DNS или vhost
Цепочка проходит, PHP отклоняет имяHostname не покрыт SANСверить URL с SAN сертификатаИсправить URL или перевыпустить сертификат
CLI проходит, PHP отклоняетРазные bundle, backend или праваСравнить curl_version и путь файлаНастроить PHP-окружение
После -k запрос проходитПроверка отключена, причина не найденаПовторить с включёнными проверкамиНе использовать обход; вернуть доверенную цепочку

Порядок действий

  1. Зафиксируйте точный URL, hostname, порт, код PHP, пользователя процесса и версии PHP, libcurl и TLS backend.
  2. Запустите openssl s_client с hostname в -servername и сохраните присланные сертификаты.
  3. Сравните leaf с запуском без SNI, если есть подозрение на неправильный виртуальный хост.
  4. Разделите leaf и intermediate и запустите openssl verify с тем CA bundle, который вы проверяете.
  5. Сверьте hostname URL с SAN сертификата. Не заменяйте URL на IP и не пытайтесь лечить TLS заголовком HTTP Host.
  6. Повторите тот же URL в PHP при CURLOPT_SSL_VERIFYPEER => true и CURLOPT_SSL_VERIFYHOST => 2.
  7. Передайте исправление правильному владельцу: серверу нужен intermediate, окружению нужен управляемый CA bundle, а сертификату или URL нужно корректное имя.

Ограничения и отрицательный путь

Формат сообщения и детали TLS зависят от версии OpenSSL, libcurl, операционной системы и backend. Поэтому сравнивайте не только текст ошибки. Записывайте команду, имя, версии, путь bundle и сертификаты, которые действительно пришли.

Успешная проверка цепочки не означает, что сервис доступен по сети, отвечает на HTTP или разрешён политикой организации. Проверка отзыва, pinning, прокси и клиентских сертификатов добавляют отдельные условия. Эта статья не заменяет их настройку.

Не принимайте CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0 или curl -k как исправление. Такой тест может подтвердить, что отказ находится в проверке TLS, но он не подтверждает безопасность соединения. После эксперимента верните проверки и продолжите поиск причины.

Частный CA также нельзя добавлять в общий публичный bundle без границы доверия. Доступный веб-процесс должен читать файл, но пользователи и загружаемые файлы не должны менять его. Если сервер прислал неполную цепочку, добавление intermediate в корневой store маскирует ошибку поставки.

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

Исправление готово, когда тот же PHP-код обращается к тому же hostname при включённых peer и hostname checks, а TLS-сеанс завершается без обходов. Дополнительно зафиксированы версия TLS backend, источник CA bundle и владелец серверной цепочки. Если запрос всё ещё падает, команда может показать, где именно расхождение: SNI, цепочка, hostname или локальное доверие. Это проверяемый результат, а не обещание production-эффекта.

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

" }