Files
progcode/editorial/agent-rewrites/338.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 338,
"slug": "editorial-2018-08-mechanism-tls-ca",
"title": "TLS в PHP cURL: как отличить CA bundle, hostname и SNI",
"excerpt": "PHP cURL может отклонить HTTPS-соединение даже тогда, когда сайт открывается в браузере. Разбираем цепочку сертификатов, имя хоста и SNI, а затем проверяем каждую причину отдельно.",
"contentHtml": "<p>PHP cURL получает сертификат от HTTPS-сервера, но останавливает запрос с ошибкой проверки. В браузере тот же адрес открывается. Команда пробует добавить повтор, заменить имя на IP или поставить <code>CURLOPT_SSL_VERIFYPEER =&gt; false</code>. Запрос начинает проходить, но клиент больше не подтверждает личность сервера. Цена ошибки — отправить токен, персональные данные или платёжный запрос не тому узлу.</p><p>У такого отказа нет одной универсальной причины. Клиент строит цепочку до доверенного корня, проверяет имя в сертификате и получает сертификат для нужного виртуального хоста. Эти проверки связаны в одном TLS-сеансе, но принадлежат разным сторонам. Если разделить их, диагностика превращается из перебора настроек в короткий набор проверок.</p><h2>Главный тезис: TLS-проверка состоит из отдельных условий</h2><p>CA bundle отвечает на вопрос «каким центрам сертификации доверяет этот процесс». Серверная цепочка отвечает на вопрос «можно ли от конечного сертификата дойти до такого центра». Проверка hostname отвечает на вопрос «выдан ли сертификат имени из URL». SNI помогает серверу выбрать сертификат до того, как клиент увидит ответ.</p><p>Браузер и PHP могут использовать разные TLS-библиотеки, хранилища и прокси. Поэтому результат в браузере не доказывает, что PHP видит тот же bundle и тот же виртуальный хост. Сначала фиксируйте URL и окружение процесса, затем проверяйте серверный ответ и локальное доверие.</p><figure><img src=\"/assets/editorial/2018/tls-ca-chain-sni-2018.svg\" alt=\"Схема проверки TLS: URL задаёт hostname, SNI выбирает виртуальный хост, сервер отдаёт цепочку, а клиент сверяет её с локальным CA bundle.\" /><figcaption>Учебная схема: SNI влияет на выбор сертификата сервером, а CA bundle и hostname проверяются клиентом.</figcaption></figure><h2>Что происходит между URL и HTTP</h2><p>cURL сначала разбирает URL. Из него он получает имя, порт и путь. Для <code>https://api.example.test/v1/ping</code> hostname — <code>api.example.test</code>. Это имя участвует в TLS-переговорах. Заголовок HTTP <code>Host</code> появляется позже. Он не исправляет сертификат, который уже был выбран и проверен на TLS-уровне.</p><p>В ClientHello TLS-клиент обычно передаёт SNI с тем же именем. Сервер использует SNI, чтобы выбрать конфигурацию виртуального хоста. На одном IP могут жить десятки сайтов. Если имя не передано или передано неверно, сервер может вернуть сертификат default-vhost. Этот сертификат может иметь корректную подпись, но не покрывать имя из URL.</p><p>После ответа сервера клиент получает конечный сертификат сайта и, как правило, промежуточные сертификаты. Корневой сертификат обычно уже находится в локальном хранилище. Клиент строит путь от leaf через intermediate к доверенному корню. Если intermediate не прислан, а локальный bundle не содержит его как доверенный якорь, путь может не построиться.</p><div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Часть</th><th scope=\"col\">Кто отвечает</th><th scope=\"col\">Что проверяется</th><th scope=\"col\">Типичный сбой</th></tr></thead><tbody><tr><td>Hostname в URL</td><td>Код и конфигурация PHP</td><td>Имя покрыто SAN сертификата</td><td>В URL указан IP или чужое имя</td></tr><tr><td>SNI</td><td>TLS-клиент и серверный vhost</td><td>Выбран сертификат нужного сайта</td><td>Отдан default-vhost</td></tr><tr><td>Leaf и intermediate</td><td>HTTPS-сервер</td><td>Строится путь сертификатов</td><td>Не прислан intermediate</td></tr><tr><td>CA bundle</td><td>Окружение PHP</td><td>Корень считается доверенным</td><td>Файл отсутствует или недоступен</td></tr></tbody></table></div><h2>Сначала снимите наблюдаемый ответ сервера</h2><p>Для диагностики нужен hostname из настоящего URL приложения. Не подставляйте IP, если хотите проверить рабочий маршрут. Команда ниже — учебный пример с вымышленным доменом. Она не доказывает состояние какого-либо production-сервера, а показывает способ получить список сертификатов с заданным SNI.</p><pre><code>openssl s_client -connect api.example.test:443 -servername api.example.test -showcerts &lt; /dev/null</code></pre><p>В выводе найдите конечный сертификат и промежуточные сертификаты. <code>-showcerts</code> показывает сертификаты, присланные сервером. Он не означает, что OpenSSL уже построил доверенную цепочку. Сохраните версию OpenSSL, hostname и саму команду рядом с результатом. Не публикуйте приватные ключи и секретные заголовки из диагностического окружения.</p><p>Затем выполните тот же учебный запрос без SNI только как контрольное сравнение:</p><pre><code>openssl s_client -connect api.example.test:443 -noservername -showcerts &lt; /dev/null</code></pre><p>Если leaf различается, сервер выбирает разные виртуальные хосты. Это не повод добавлять полученный сертификат в CA bundle. Проверьте DNS, URL, балансировщик и конфигурацию vhost. Если сертификаты одинаковы, переходите к цепочке и локальному trust store.</p><h2>Проверьте цепочку без PHP и HTTP</h2><p>Разделите сохранённый ответ на <code>leaf.pem</code> и <code>intermediate.pem</code>. Доверенный bundle храните отдельно в <code>ca-bundle.pem</code>. В учебной команде <code>-CAfile</code> задаёт доверенные корни, а <code>-untrusted</code> добавляет сертификаты для построения пути. Intermediate не становится доверенным только потому, что его прислал сервер.</p><pre><code>openssl verify -purpose sslserver -CAfile ./ca-bundle.pem -untrusted ./intermediate.pem ./leaf.pem</code></pre><p>Успех этой команды означает, что для этих файлов OpenSSL построил допустимую цепочку. Он не подтверждает hostname рабочего URL и не проверяет, что PHP загрузил именно этот файл. Ошибка «unable to get local issuer certificate» может означать неполный ответ сервера, отсутствующий корень или другой bundle. Одна строка ошибки не выбирает ветку сама.</p><p>Проверьте срок действия, назначение сертификата и имя в его SAN. Не переносите intermediate в список корней ради зелёного результата. Если сервер не отправляет нужный intermediate, исправление принадлежит владельцу HTTPS-сервера. Если частный корень нужен внутреннему сервису, его распространяет владелец инфраструктуры через управляемый пакет или секрет.</p><h2>Проверьте тот же URL в PHP cURL</h2><p>В PHP явно задайте путь к bundle только для учебного примера. В рабочей системе путь должен приходить из конфигурации или управляемого окружения. Проверка peer и hostname должна оставаться включённой.</p><pre><code>&lt;?php $ch = curl_init('https://api.example.test/v1/ping'); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER =&gt; true, CURLOPT_CAINFO =&gt; '/opt/app/certs/ca-bundle.pem', CURLOPT_SSL_VERIFYPEER =&gt; true, CURLOPT_SSL_VERIFYHOST =&gt; 2]); $body = curl_exec($ch); if ($body === false) { throw new RuntimeException(curl_errno($ch) . ': ' . curl_error($ch)); } curl_close($ch);</code></pre><p>Если CLI cURL проходит, а PHP нет, сравните <code>curl_version()</code>, TLS backend, путь CAfile, права чтения и пользователя процесса. Браузер мог использовать системное хранилище, а PHP — файл из сборки libcurl или значение <code>curl.cainfo</code>. Проверяйте фактический процесс, а не только интерактивную shell-сессию.</p><h2>Симптом → причина → проверка → действие</h2><div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>С SNI приходит ожидаемый leaf, но verify не строит путь</td><td>Нет intermediate или корня в bundle</td><td>Разделить PEM и запустить verify</td><td>Исправить серверную цепочку или CA store</td></tr><tr><td>Leaf меняется при отключении SNI</td><td>Выбран другой TLS virtual host</td><td>Сравнить два запуска s_client</td><td>Исправить hostname, DNS или vhost</td></tr><tr><td>Цепочка проходит, PHP отклоняет имя</td><td>Hostname не покрыт SAN</td><td>Сверить URL с SAN сертификата</td><td>Исправить URL или перевыпустить сертификат</td></tr><tr><td>CLI проходит, PHP отклоняет</td><td>Разные bundle, backend или права</td><td>Сравнить curl_version и путь файла</td><td>Настроить PHP-окружение</td></tr><tr><td>После <code>-k</code> запрос проходит</td><td>Проверка отключена, причина не найдена</td><td>Повторить с включёнными проверками</td><td>Не использовать обход; вернуть доверенную цепочку</td></tr></tbody></table></div><h2>Порядок действий</h2><ol><li>Зафиксируйте точный URL, hostname, порт, код PHP, пользователя процесса и версии PHP, libcurl и TLS backend.</li><li>Запустите <code>openssl s_client</code> с hostname в <code>-servername</code> и сохраните присланные сертификаты.</li><li>Сравните leaf с запуском без SNI, если есть подозрение на неправильный виртуальный хост.</li><li>Разделите leaf и intermediate и запустите <code>openssl verify</code> с тем CA bundle, который вы проверяете.</li><li>Сверьте hostname URL с SAN сертификата. Не заменяйте URL на IP и не пытайтесь лечить TLS заголовком HTTP Host.</li><li>Повторите тот же URL в PHP при <code>CURLOPT_SSL_VERIFYPEER =&gt; true</code> и <code>CURLOPT_SSL_VERIFYHOST =&gt; 2</code>.</li><li>Передайте исправление правильному владельцу: серверу нужен intermediate, окружению нужен управляемый CA bundle, а сертификату или URL нужно корректное имя.</li></ol><h2>Ограничения и отрицательный путь</h2><p>Формат сообщения и детали TLS зависят от версии OpenSSL, libcurl, операционной системы и backend. Поэтому сравнивайте не только текст ошибки. Записывайте команду, имя, версии, путь bundle и сертификаты, которые действительно пришли.</p><p>Успешная проверка цепочки не означает, что сервис доступен по сети, отвечает на HTTP или разрешён политикой организации. Проверка отзыва, pinning, прокси и клиентских сертификатов добавляют отдельные условия. Эта статья не заменяет их настройку.</p><p>Не принимайте <code>CURLOPT_SSL_VERIFYPEER =&gt; false</code>, <code>CURLOPT_SSL_VERIFYHOST =&gt; 0</code> или <code>curl -k</code> как исправление. Такой тест может подтвердить, что отказ находится в проверке TLS, но он не подтверждает безопасность соединения. После эксперимента верните проверки и продолжите поиск причины.</p><p>Частный CA также нельзя добавлять в общий публичный bundle без границы доверия. Доступный веб-процесс должен читать файл, но пользователи и загружаемые файлы не должны менять его. Если сервер прислал неполную цепочку, добавление intermediate в корневой store маскирует ошибку поставки.</p><h2>Проверяемый критерий готовности</h2><p>Исправление готово, когда тот же PHP-код обращается к тому же hostname при включённых peer и hostname checks, а TLS-сеанс завершается без обходов. Дополнительно зафиксированы версия TLS backend, источник CA bundle и владелец серверной цепочки. Если запрос всё ещё падает, команда может показать, где именно расхождение: SNI, цепочка, hostname или локальное доверие. Это проверяемый результат, а не обещание production-эффекта.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://curl.se/docs/sslcerts.html\" target=\"_blank\" rel=\"noopener noreferrer\">curl: SSL CA Certificates</a> — официальное описание проверки сертификата, CA store и безопасного использования custom CA.</li><li><a href=\"https://www.php.net/manual/en/curl.constants.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP Manual: cURL constants</a> — официальные параметры <code>CURLOPT_SSL_VERIFYPEER</code>, <code>CURLOPT_SSL_VERIFYHOST</code> и <code>CURLOPT_CAINFO</code>.</li><li><a href=\"https://docs.openssl.org/master/man1/openssl-s_client/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenSSL: s_client</a> — официальная документация по <code>-servername</code>, <code>-showcerts</code> и параметрам проверки.</li></ul>"
}