8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"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 => false</code>. Запрос начинает проходить, но клиент больше не подтверждает личность сервера. Цена ошибки — отправить токен, персональные данные или платёжный запрос не тому узлу.</p>\n<p>У такого отказа нет одной универсальной причины. Клиент строит цепочку до доверенного корня, проверяет имя в сертификате и получает сертификат для нужного виртуального хоста. Эти проверки связаны в одном TLS-сеансе, но принадлежат разным сторонам. Если разделить их, диагностика превращается из перебора настроек в короткий набор проверок.</p>\n<h2>Главный тезис: TLS-проверка состоит из отдельных условий</h2>\n<p>CA bundle — это условное название источника доверия клиента: чаще файл с сертификатами, но конкретный TLS backend может использовать и системное хранилище. Он отвечает на вопрос «каким центрам сертификации доверяет этот процесс». Серверная цепочка отвечает на вопрос «можно ли от конечного сертификата дойти до такого центра».</p>\n<p>Проверка hostname отвечает на вопрос «покрывает ли сертификат имя из URL». SNI, или Server Name Indication, помогает серверу выбрать конфигурацию и сертификат виртуального хоста до отправки сертификата клиенту. Ни CA bundle, ни hostname check не исправляют неверный выбор vhost, а SNI не заменяет проверку доверия.</p>\n<p>Браузер и PHP могут использовать разные TLS-библиотеки, хранилища и прокси. Поэтому результат в браузере не доказывает, что PHP видит тот же bundle и тот же виртуальный хост. Сначала фиксируйте URL и окружение процесса, затем проверяйте серверный ответ и локальное доверие.</p>\n<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>\n<h2>Что происходит между URL и HTTP</h2>\n<p>cURL сначала разбирает URL. Из него он получает имя, порт и путь. Для <code>https://api.example.test/v1/ping</code> hostname — <code>api.example.test</code>. Это имя участвует в TLS-переговорах. Заголовок HTTP <code>Host</code> появляется позже, уже внутри HTTP-обмена. Он не исправляет сертификат, который сервер выбрал и клиент проверил на TLS-уровне.</p>\n<p>В ClientHello TLS-клиент обычно передаёт SNI с тем же именем. Сервер использует SNI, чтобы выбрать конфигурацию виртуального хоста. На одном IP могут жить десятки сайтов. Если имя не передано или передано неверно, сервер может вернуть сертификат default-vhost. Этот сертификат может иметь корректную подпись, но не покрывать имя из URL.</p>\n<p>В TLS handshake сервер присылает конечный сертификат сайта и, как правило, промежуточные сертификаты. Корневой сертификат обычно уже находится в локальном хранилище. Клиент строит путь от leaf через intermediate к доверенному корню. Если intermediate не прислан, а локальный trust store не содержит подходящий сертификат для построения пути, проверка может завершиться ошибкой.</p>\n<div class=\"table-scroll\"><table><caption>Участники проверки TLS и границы ответственности</caption><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>\n<h2>Сначала снимите наблюдаемый ответ сервера</h2>\n<p>Для диагностики нужен hostname из настоящего URL приложения. Не подставляйте IP, если хотите проверить рабочий маршрут. Команды ниже используют зарезервированный учебный домен <code>api.partner.example</code>. Они показывают способ получить список сертификатов с заданным SNI, но не доказывают состояние какого-либо production-сервера.</p>\n<pre><code>openssl s_client \\\n -connect api.partner.example:443 \\\n -servername api.partner.example \\\n -showcerts \\\n < /dev/null</code></pre>\n<p>В выводе найдите конечный сертификат и промежуточные сертификаты. <code>-showcerts</code> показывает сертификаты, присланные сервером, в порядке ответа. Это не готовый вердикт доверия и не построенная цепочка. Сохраните версию OpenSSL, hostname и саму команду рядом с результатом. Не публикуйте приватные ключи и секретные заголовки из диагностического окружения.</p>\n<p>Затем выполните контрольный запуск без расширения SNI:</p>\n<pre><code>openssl s_client \\\n -connect api.partner.example:443 \\\n -noservername \\\n -showcerts \\\n < /dev/null</code></pre>\n<p>В OpenSSL 1.1.1 и новее имя из DNS-формы <code>-connect</code> может автоматически попасть в SNI, поэтому для сравнения без расширения нужен явный <code>-noservername</code>. Если leaf различается, сервер действительно выбирает разные конфигурации. Это не повод добавлять полученный сертификат в CA bundle: проверьте DNS, URL, балансировщик и vhost. Если сертификаты одинаковы, переходите к цепочке и локальному trust store.</p>\n<h2>Проверьте цепочку и имя без PHP и HTTP</h2>\n<p>Разделите сохранённый ответ на <code>leaf.pem</code> и <code>intermediate.pem</code>. Доверенный bundle храните отдельно в <code>ca-bundle.pem</code>. В команде ниже <code>-CAfile</code> задаёт доверенные сертификаты, а <code>-untrusted</code> добавляет промежуточные сертификаты только для построения пути. Intermediate не становится доверенным только потому, что его прислал сервер.</p>\n<pre><code>openssl verify \\\n -purpose sslserver \\\n -verify_hostname api.partner.example \\\n -CAfile ./ca-bundle.pem \\\n -untrusted ./intermediate.pem \\\n ./leaf.pem</code></pre>\n<p>Эта команда проверяет назначение серверного сертификата, строит цепочку и сверяет hostname с именем в сертификате. Если в старой сборке OpenSSL нет <code>-verify_hostname</code>, выполните проверку цепочки без этой опции, а имя сверьте с SAN отдельно. Успех команды означает, что для выбранных файлов путь и имя прошли проверку; он всё ещё не подтверждает, что PHP загрузил именно этот bundle.</p>\n<p>Ошибка <code>unable to get local issuer certificate</code> может означать неполный ответ сервера, отсутствующий корень, неподходящий intermediate или другой bundle. Одна строка ошибки не выбирает ветку сама. Проверьте срок действия, назначение и SAN сертификата. Не переносите intermediate в список корней ради зелёного результата. Если сервер не отправляет нужный intermediate, исправление принадлежит владельцу HTTPS-сервера. Если частный корень нужен внутреннему сервису, его распространяет владелец инфраструктуры через управляемый пакет или секрет.</p>\n<h2>Проверьте тот же URL в PHP cURL</h2>\n<p>В PHP явно задайте путь к bundle только для учебного примера. В рабочей системе путь должен приходить из конфигурации или управляемого окружения. Проверка peer и hostname должна оставаться включённой.</p>\n<pre><code><?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;</code></pre>\n<p>Вызовы <code>curl_errno()</code> и <code>curl_error()</code> стоят до <code>curl_close()</code>. Номер и текст нужно сохранять вместе с hostname, временем, версией PHP cURL и идентификатором запроса. Путь <code>CURLOPT_CAINFO</code> должен быть доступен пользователю PHP-процесса и не должен приходить из HTTP-параметра.</p>\n<p>Если CLI cURL проходит, а PHP нет, сравните <code>curl_version()</code>, TLS backend, путь CAfile, права чтения и пользователя процесса. Браузер мог использовать системное хранилище, а PHP — файл из сборки libcurl или значение <code>curl.cainfo</code>. Проверяйте фактический процесс, а не только интерактивную shell-сессию.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Матрица диагностики отказа TLS в PHP cURL</caption><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>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксируйте точный URL, hostname, порт, код PHP, пользователя процесса и версии PHP, libcurl и TLS backend.</li><li>Запустите <code>openssl s_client</code> с hostname в <code>-servername</code> и сохраните присланные сертификаты.</li><li>Сравните leaf с запуском с <code>-noservername</code>, если есть подозрение на неправильный виртуальный хост.</li><li>Разделите leaf и intermediate и запустите <code>openssl verify</code> с <code>-purpose sslserver</code>, <code>-verify_hostname</code> и тем CA bundle, который вы проверяете.</li><li>Если <code>-verify_hostname</code> недоступен, отдельно сверяйте hostname URL с SAN сертификата. Не заменяйте URL на IP и не пытайтесь лечить TLS заголовком HTTP <code>Host</code>.</li><li>Повторите тот же URL в PHP при <code>CURLOPT_SSL_VERIFYPEER => true</code> и <code>CURLOPT_SSL_VERIFYHOST => 2</code>.</li><li>Передайте исправление правильному владельцу: серверу нужен intermediate, окружению нужен управляемый CA bundle, а сертификату или URL нужно корректное имя.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Формат сообщения и детали TLS зависят от версии OpenSSL, libcurl, операционной системы и backend. Поэтому сравнивайте не только текст ошибки. Записывайте команду, имя, версии, путь bundle и сертификаты, которые действительно пришли.</p>\n<p>Успешная проверка цепочки не означает, что сервис доступен по сети, отвечает на HTTP или разрешён политикой организации. Проверка отзыва, pinning, прокси и клиентских сертификатов добавляют отдельные условия. Эта статья не заменяет их настройку.</p>\n<p>Не принимайте <code>CURLOPT_SSL_VERIFYPEER => false</code>, <code>CURLOPT_SSL_VERIFYHOST => 0</code> или <code>curl -k</code> как исправление. Такой тест может подтвердить, что отказ находится в проверке TLS, но он не подтверждает безопасность соединения. После эксперимента верните проверки и продолжите поиск причины.</p>\n<p>Частный CA также нельзя добавлять в общий публичный bundle без границы доверия. Доступный веб-процесс должен читать файл, но пользователи и загружаемые файлы не должны менять его. Если сервер прислал неполную цепочку, добавление intermediate в корневой store маскирует ошибку поставки.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Исправление готово, когда тот же PHP-код обращается к тому же hostname при включённых peer и hostname checks, а TLS-сеанс завершается без обходов. Дополнительно зафиксированы версия TLS backend, источник CA bundle и владелец серверной цепочки. Если запрос всё ещё падает, команда может показать, где именно расхождение: SNI, цепочка, hostname или локальное доверие. Это проверяемый результат, а не обещание production-эффекта.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://curl.se/docs/sslcerts.html\" target=\"_blank\" rel=\"noopener noreferrer\">curl: SSL CA Certificates</a> — официальное описание проверки сертификата, CA store, custom CA и безопасного использования <code>-k</code>.</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_CAINFO</code>, <code>CURLOPT_SSL_VERIFYPEER</code> и <code>CURLOPT_SSL_VERIFYHOST</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>-noservername</code> и <code>-showcerts</code>.</li><li><a href=\"https://docs.openssl.org/master/man1/openssl-verification-options/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenSSL: Verification Options</a> — документация по trust store, <code>-CAfile</code>, <code>-untrusted</code>, <code>-purpose</code> и <code>-verify_hostname</code>.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc6066\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 6066: TLS Extensions</a> — нормативное описание SNI и выбора сервером подходящего сертификата.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc6125\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 6125: Service Identity</a> — правила сопоставления имени сервиса с идентификаторами сертификата.</li></ul>"
|
||
}
|