diff --git a/editorial/agent-rewrites/338.json b/editorial/agent-rewrites/338.json index 50ad546..91777d9 100644 --- a/editorial/agent-rewrites/338.json +++ b/editorial/agent-rewrites/338.json @@ -3,5 +3,5 @@ "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-сеансе, но принадлежат разным сторонам. Если разделить их, диагностика превращается из перебора настроек в короткий набор проверок.
CA bundle отвечает на вопрос «каким центрам сертификации доверяет этот процесс». Серверная цепочка отвечает на вопрос «можно ли от конечного сертификата дойти до такого центра». Проверка hostname отвечает на вопрос «выдан ли сертификат имени из URL». SNI помогает серверу выбрать сертификат до того, как клиент увидит ответ.
Браузер и PHP могут использовать разные TLS-библиотеки, хранилища и прокси. Поэтому результат в браузере не доказывает, что PHP видит тот же bundle и тот же виртуальный хост. Сначала фиксируйте URL и окружение процесса, затем проверяйте серверный ответ и локальное доверие.
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 или чужое имя |
| SNI | TLS-клиент и серверный vhost | Выбран сертификат нужного сайта | Отдан default-vhost |
| Leaf и intermediate | HTTPS-сервер | Строится путь сертификатов | Не прислан 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.
Разделите сохранённый ответ на 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-сервера. Если частный корень нужен внутреннему сервису, его распространяет владелец инфраструктуры через управляемый пакет или секрет.
В 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 запрос проходит | Проверка отключена, причина не найдена | Повторить с включёнными проверками | Не использовать обход; вернуть доверенную цепочку |
openssl s_client с hostname в -servername и сохраните присланные сертификаты.openssl verify с тем CA bundle, который вы проверяете.CURLOPT_SSL_VERIFYPEER => true и CURLOPT_SSL_VERIFYHOST => 2.Формат сообщения и детали 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-эффекта.
CURLOPT_SSL_VERIFYPEER, CURLOPT_SSL_VERIFYHOST и CURLOPT_CAINFO.-servername, -showcerts и параметрам проверки.PHP cURL получает сертификат от HTTPS-сервера, но останавливает запрос с ошибкой проверки. В браузере тот же адрес открывается. Команда пробует добавить повтор, заменить имя на IP или поставить CURLOPT_SSL_VERIFYPEER => false. Запрос начинает проходить, но клиент больше не подтверждает личность сервера. Цена ошибки — отправить токен, персональные данные или платёжный запрос не тому узлу.
У такого отказа нет одной универсальной причины. Клиент строит цепочку до доверенного корня, проверяет имя в сертификате и получает сертификат для нужного виртуального хоста. Эти проверки связаны в одном TLS-сеансе, но принадлежат разным сторонам. Если разделить их, диагностика превращается из перебора настроек в короткий набор проверок.
\nCA bundle — это условное название источника доверия клиента: чаще файл с сертификатами, но конкретный TLS backend может использовать и системное хранилище. Он отвечает на вопрос «каким центрам сертификации доверяет этот процесс». Серверная цепочка отвечает на вопрос «можно ли от конечного сертификата дойти до такого центра».
\nПроверка hostname отвечает на вопрос «покрывает ли сертификат имя из URL». SNI, или Server Name Indication, помогает серверу выбрать конфигурацию и сертификат виртуального хоста до отправки сертификата клиенту. Ни CA bundle, ни hostname check не исправляют неверный выбор vhost, а SNI не заменяет проверку доверия.
\nБраузер и PHP могут использовать разные TLS-библиотеки, хранилища и прокси. Поэтому результат в браузере не доказывает, что PHP видит тот же bundle и тот же виртуальный хост. Сначала фиксируйте URL и окружение процесса, затем проверяйте серверный ответ и локальное доверие.
\ncURL сначала разбирает URL. Из него он получает имя, порт и путь. Для https://api.example.test/v1/ping hostname — api.example.test. Это имя участвует в TLS-переговорах. Заголовок HTTP Host появляется позже, уже внутри HTTP-обмена. Он не исправляет сертификат, который сервер выбрал и клиент проверил на TLS-уровне.
В ClientHello TLS-клиент обычно передаёт SNI с тем же именем. Сервер использует SNI, чтобы выбрать конфигурацию виртуального хоста. На одном IP могут жить десятки сайтов. Если имя не передано или передано неверно, сервер может вернуть сертификат default-vhost. Этот сертификат может иметь корректную подпись, но не покрывать имя из URL.
\nВ TLS handshake сервер присылает конечный сертификат сайта и, как правило, промежуточные сертификаты. Корневой сертификат обычно уже находится в локальном хранилище. Клиент строит путь от leaf через intermediate к доверенному корню. Если intermediate не прислан, а локальный trust store не содержит подходящий сертификат для построения пути, проверка может завершиться ошибкой.
\n| Часть | Кто отвечает | Что проверяется | Типичный сбой |
|---|---|---|---|
| Hostname в URL | Код и конфигурация PHP | Имя покрыто SAN сертификата | В URL указан IP или чужое имя |
| SNI | TLS-клиент и серверный vhost | Выбран сертификат нужного сайта | Отдан default-vhost |
| Leaf и intermediate | HTTPS-сервер | Строится путь сертификатов | Не прислан intermediate |
| CA bundle | Окружение PHP | Корень считается доверенным | Файл отсутствует или недоступен |
Для диагностики нужен hostname из настоящего URL приложения. Не подставляйте IP, если хотите проверить рабочий маршрут. Команды ниже используют зарезервированный учебный домен api.partner.example. Они показывают способ получить список сертификатов с заданным SNI, но не доказывают состояние какого-либо production-сервера.
openssl s_client \\\n -connect api.partner.example:443 \\\n -servername api.partner.example \\\n -showcerts \\\n < /dev/null\nВ выводе найдите конечный сертификат и промежуточные сертификаты. -showcerts показывает сертификаты, присланные сервером, в порядке ответа. Это не готовый вердикт доверия и не построенная цепочка. Сохраните версию OpenSSL, hostname и саму команду рядом с результатом. Не публикуйте приватные ключи и секретные заголовки из диагностического окружения.
Затем выполните контрольный запуск без расширения SNI:
\nopenssl s_client \\\n -connect api.partner.example:443 \\\n -noservername \\\n -showcerts \\\n < /dev/null\nВ OpenSSL 1.1.1 и новее имя из DNS-формы -connect может автоматически попасть в SNI, поэтому для сравнения без расширения нужен явный -noservername. Если leaf различается, сервер действительно выбирает разные конфигурации. Это не повод добавлять полученный сертификат в CA bundle: проверьте DNS, URL, балансировщик и vhost. Если сертификаты одинаковы, переходите к цепочке и локальному trust store.
Разделите сохранённый ответ на leaf.pem и intermediate.pem. Доверенный bundle храните отдельно в ca-bundle.pem. В команде ниже -CAfile задаёт доверенные сертификаты, а -untrusted добавляет промежуточные сертификаты только для построения пути. Intermediate не становится доверенным только потому, что его прислал сервер.
openssl verify \\\n -purpose sslserver \\\n -verify_hostname api.partner.example \\\n -CAfile ./ca-bundle.pem \\\n -untrusted ./intermediate.pem \\\n ./leaf.pem\nЭта команда проверяет назначение серверного сертификата, строит цепочку и сверяет hostname с именем в сертификате. Если в старой сборке OpenSSL нет -verify_hostname, выполните проверку цепочки без этой опции, а имя сверьте с SAN отдельно. Успех команды означает, что для выбранных файлов путь и имя прошли проверку; он всё ещё не подтверждает, что PHP загрузил именно этот bundle.
Ошибка unable to get local issuer certificate может означать неполный ответ сервера, отсутствующий корень, неподходящий intermediate или другой bundle. Одна строка ошибки не выбирает ветку сама. Проверьте срок действия, назначение и SAN сертификата. Не переносите intermediate в список корней ради зелёного результата. Если сервер не отправляет нужный intermediate, исправление принадлежит владельцу HTTPS-сервера. Если частный корень нужен внутреннему сервису, его распространяет владелец инфраструктуры через управляемый пакет или секрет.
В PHP явно задайте путь к bundle только для учебного примера. В рабочей системе путь должен приходить из конфигурации или управляемого окружения. Проверка peer и hostname должна оставаться включённой.
\n<?php\n$url = 'https://api.partner.example/v1/ping';\n$caFile = '/opt/app/certs/ca-bundle.pem';\n\n$handle = curl_init($url);\nif ($handle === false) {\n throw new RuntimeException('Не удалось создать cURL handle');\n}\n\ncurl_setopt_array($handle, [\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_CAINFO => $caFile,\n CURLOPT_SSL_VERIFYPEER => true,\n CURLOPT_SSL_VERIFYHOST => 2,\n]);\n\n$body = curl_exec($handle);\n$errno = curl_errno($handle);\n$error = curl_error($handle);\ncurl_close($handle);\n\nif ($body === false) {\n throw new RuntimeException($errno . ': ' . $error);\n}\n\necho $body;\nВызовы curl_errno() и curl_error() стоят до curl_close(). Номер и текст нужно сохранять вместе с hostname, временем, версией PHP cURL и идентификатором запроса. Путь CURLOPT_CAINFO должен быть доступен пользователю PHP-процесса и не должен приходить из HTTP-параметра.
Если 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 запрос проходит | Проверка отключена, причина не найдена | Повторить с включёнными проверками | Не использовать обход; вернуть доверенную цепочку |
openssl s_client с hostname в -servername и сохраните присланные сертификаты.-noservername, если есть подозрение на неправильный виртуальный хост.openssl verify с -purpose sslserver, -verify_hostname и тем CA bundle, который вы проверяете.-verify_hostname недоступен, отдельно сверяйте hostname URL с SAN сертификата. Не заменяйте URL на IP и не пытайтесь лечить TLS заголовком HTTP Host.CURLOPT_SSL_VERIFYPEER => true и CURLOPT_SSL_VERIFYHOST => 2.Формат сообщения и детали TLS зависят от версии OpenSSL, libcurl, операционной системы и backend. Поэтому сравнивайте не только текст ошибки. Записывайте команду, имя, версии, путь bundle и сертификаты, которые действительно пришли.
\nУспешная проверка цепочки не означает, что сервис доступен по сети, отвечает на HTTP или разрешён политикой организации. Проверка отзыва, pinning, прокси и клиентских сертификатов добавляют отдельные условия. Эта статья не заменяет их настройку.
\nНе принимайте CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0 или curl -k как исправление. Такой тест может подтвердить, что отказ находится в проверке TLS, но он не подтверждает безопасность соединения. После эксперимента верните проверки и продолжите поиск причины.
Частный CA также нельзя добавлять в общий публичный bundle без границы доверия. Доступный веб-процесс должен читать файл, но пользователи и загружаемые файлы не должны менять его. Если сервер прислал неполную цепочку, добавление intermediate в корневой store маскирует ошибку поставки.
\nИсправление готово, когда тот же PHP-код обращается к тому же hostname при включённых peer и hostname checks, а TLS-сеанс завершается без обходов. Дополнительно зафиксированы версия TLS backend, источник CA bundle и владелец серверной цепочки. Если запрос всё ещё падает, команда может показать, где именно расхождение: SNI, цепочка, hostname или локальное доверие. Это проверяемый результат, а не обещание production-эффекта.
\n-k.CURLOPT_CAINFO, CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST.-servername, -noservername и -showcerts.-CAfile, -untrusted, -purpose и -verify_hostname.