Files

8 lines
21 KiB
JSON
Raw Permalink 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>\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 &lt; /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 &lt; /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>&lt;?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 =&gt; true,\n CURLOPT_CAINFO =&gt; $caFile,\n CURLOPT_SSL_VERIFYPEER =&gt; true,\n CURLOPT_SSL_VERIFYHOST =&gt; 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 =&gt; true</code> и <code>CURLOPT_SSL_VERIFYHOST =&gt; 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 =&gt; false</code>, <code>CURLOPT_SSL_VERIFYHOST =&gt; 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>"
}