8 lines
25 KiB
JSON
8 lines
25 KiB
JSON
{
|
||
"index": 8,
|
||
"slug": "editorial-2027-10-mechanism-long-form-interview",
|
||
"title": "TLS-сертификат: почему «curl работает» не закрывает проверку",
|
||
"excerpt": "Один успешный запрос доказывает только один набор условий: маршрут, SNI, часы, trust store и политику конкретного клиента. Разбираем, как отделить эти проверки и исправить причину без отключения TLS.",
|
||
"contentHtml": "<p>Симптом знакомый: <code>curl -I https://api.example.test/health</code> с ноутбука возвращает ответ, а приложение в контейнере получает ошибку сертификата. Иногда браузер открывает тот же адрес, но PHP cURL сообщает <code>unable to get local issuer certificate</code>. Цена неверного вывода — не только потерянное время. Если сделать запрос успешным через отключение проверки, сервис может отправить токен или платёжные данные узлу, чью личность он не подтвердил.</p>\n<p>Главный вопрос здесь не «работает ли curl», а «какой именно клиент, с каким trust store и каким именем прошёл какие проверки». Успешный запрос доказывает конкретный маршрут, часы, TLS-библиотеку, набор доверенных центров сертификации и политику одного процесса. Это не сертификат исправности браузера, другого контейнера или production-конфигурации. Ниже разберём границы доказательства и оставим runbook, который можно повторить в том же окружении, где живёт ошибка.</p>\n<h2>Успешен не «curl», а конкретный verifier</h2>\n<p>TLS не начинается с HTTP-статуса. Сначала клиент устанавливает соединение, договаривается о параметрах и получает от сервера сертификат или цепочку сертификатов. Затем он решает, можно ли доверять цепочке и подходит ли заявленная идентичность имени из URL. Только после успешного обычного handshake появляется защищённый канал для HTTP.</p>\n<p>Поэтому фраза «curl работает» слишком короткая. Она может означать: CLI использовал встроенный путь к CA bundle, попал на другой IP, получил сертификат для нужного виртуального хоста и проверил его по часам этой машины. PHP-процесс в контейнере может использовать другой libcurl, другой TLS backend, другой файл доверия, другой proxy и другое системное время. Оба результата будут честными, но отвечать на разные вопросы.</p>\n<figure><img src=\"/assets/editorial/2027/long-form-interview-2027-source-boundary-matrix.svg\" alt=\"Матрица проверки TLS-сертификата: клиент отдельно проверяет срок действия, имя SAN, цепочку доверия и разрешённую криптографическую политику.\" loading=\"lazy\" /><figcaption>Один клиентский запрос проходит несколько условий. Исправлять нужно тот слой, который дал отказ, а не отключать всю проверку.</figcaption></figure>\n<pre><code>URL и hostname\n -> DNS и TCP-маршрут\n -> ClientHello с SNI (если клиент его отправляет)\n -> сертификат и цепочка от сервера\n -> путь до доверенного CA + срок + политика\n -> совпадение имени с сертификатом\n -> обычный HTTP-запрос</code></pre>\n<p>В TLS 1.3 серверное сообщение <code>Certificate</code> передаёт цепочку, когда аутентификация опирается на сертификат. Сам протокол описывает handshake и защищённый канал, но подробные правила проверки X.509 и сопоставления имени дополняются профилями и политикой клиента. Это важная граница: TLS сообщает, как обменяться сертификатом и ключами, но не выбирает за приложение доверенный корень.</p>\n<h2>Пять проверок, которые нельзя смешивать</h2>\n<div class=\"table-scroll\"><table><caption>Что доказывает диагностика и чего она не доказывает</caption><thead><tr><th scope=\"col\">Слой</th><th scope=\"col\">Кто владеет</th><th scope=\"col\">Успех означает</th><th scope=\"col\">Ещё нужно проверить</th></tr></thead><tbody><tr><td>Маршрут</td><td>DNS, сеть, proxy</td><td>Клиент дошёл до некоторого TLS endpoint</td><td>Что это нужный IP и proxy-путь</td></tr><tr><td>SNI и имя</td><td>Клиент и TLS-сервер</td><td>Выбранный сертификат покрывает hostname из URL</td><td>SAN, redirect и имя, которое видит приложение</td></tr><tr><td>Цепочка</td><td>Сервер и trust store клиента</td><td>Из leaf можно построить путь к доверенному CA</td><td>Все intermediate и состав доверия этого процесса</td></tr><tr><td>Время</td><td>Часы клиента</td><td><code>now</code> попадает в окно <code>notBefore</code>–<code>notAfter</code></td><td>UTC-время контейнера и узла</td></tr><tr><td>Политика</td><td>TLS backend и приложение</td><td>Алгоритмы, версии и режим проверки разрешены</td><td>Настройки конкретной сборки и требования endpoint</td></tr></tbody></table></div>\n<p>Сертификат не является одним флагом «валиден». RFC 5280 описывает путь сертификации: сертификаты в цепочке должны связываться по issuer/subject, быть действительными в рассматриваемый момент и вести к trust anchor. Выбор trust anchor — локальная политика. Поэтому одинаковый PEM-файл на двух машинах не гарантирует одинаковый результат, если процессы читают разные файлы или один из них использует системное хранилище.</p>\n<p>Время — такой же вход, как URL. Поле <code>notBefore</code> задаёт начало, а <code>notAfter</code> — конец периода действия. Отставшие часы дают ошибку «ещё не действителен», спешащие — «истёк». Повторный запрос или новый DNS-ответ это не исправят. Снимайте время именно внутри контейнера или виртуальной машины, где запущен клиент.</p>\n<h2>Имя сертификата и SNI — не одно и то же</h2>\n<p>SNI (Server Name Indication) — расширение ClientHello, в котором клиент сообщает имя сервера. Виртуальный хост использует его, чтобы выбрать сертификат среди нескольких конфигураций на одном IP. После этого клиент всё равно должен сопоставить ожидаемое имя с идентификатором в сертификате. SNI помогает получить правильный сертификат, но не превращает неправильное имя в правильное.</p>\n<p>Для HTTPS ожидаемое имя обычно берётся из hostname URL. Заголовок <code>Host</code> отправляется на HTTP-уровне позже. Если клиент подключился к IP и получил сертификат default-vhost, добавление другого <code>Host</code> не отменяет уже случившийся отказ TLS. Когда нужно проверить конкретный IP, сохраняйте hostname и меняйте только маршрут. Для этого подходит контролируемый тест вроде <code>curl --resolve api.example.test:443:IP https://api.example.test/health</code>, где <code>IP</code> заменяется на адрес из вашей инфраструктуры.</p>\n<p>RFC 9525 описывает service identity через reference identifier и presented identifier. Для DNS-имени это означает сравнение имени клиента с DNS-ID в <code>subjectAltName</code>; wildcard тоже подчиняется правилам сопоставления, а не произвольному поиску подстроки. Поэтому проверка «в сертификате где-то встречается нужное слово» недостаточна. Смотрите SAN и фактический hostname, а не только Common Name в старом просмотрщике.</p>\n<h2>Почему разные клиенты дают разные ответы</h2>\n<p>CLI cURL и PHP cURL могут выглядеть одинаково в логе, но иметь разную конфигурацию. Официальная документация cURL отмечает, что CA store зависит от сборки и TLS backend: в одних окружениях используется файл, в других — нативное хранилище ОС. PHP задаёт <code>curl.cainfo</code> как значение по умолчанию для <code>CURLOPT_CAINFO</code>, и для него нужен абсолютный путь. Это уже две точки расхождения до того, как приложение добавило собственные настройки.</p>\n<p>Проверка peer и проверка имени — отдельные операции. <code>CURLOPT_SSL_VERIFYPEER => true</code> проверяет, что сертификат можно связать с доверенным CA. <code>CURLOPT_SSL_VERIFYHOST => 2</code> проверяет заявленное имя. Выключение первой операции не исправляет вторую; выключение обеих только убирает доказательство личности. Шифрование канала при этом может остаться, но оно не отвечает на вопрос, с тем ли endpoint вы говорите.</p>\n<p>Опция <code>--cacert</code> или <code>CURLOPT_CAINFO</code> — способ явно указать доверенный CA bundle для конкретного клиента. Это не команда «взять сертификат у сервера и доверять ему». Для публичного endpoint нужен поставляемый и проверенный набор доверенных CA; для частного CA — согласованный владельцем инфраструктуры сертификат корневого центра и контролируемая доставка. Если сервер не прислал intermediate, добавление leaf в общий bundle маскирует проблему вместо исправления серверной цепочки.</p>\n<h2>Самодостаточный PHP-пример</h2>\n<p>Ниже минимальный CLI-скрипт без фреймворка. Он принимает URL и, при необходимости, существующий абсолютный путь к CA bundle. В коде проверка peer и имени включена явно, а номер ошибки и текст сохраняются до закрытия дескриптора. Значения таймаутов здесь учебные: они не являются рекомендацией для каждого API.</p>\n<pre><code><?php\nfunction request($url, $caFile = null)\n{\n if ($caFile !== null and !is_readable($caFile)) {\n throw new RuntimeException('CA file is not readable: ' . $caFile);\n }\n\n $handle = curl_init($url);\n if ($handle === false) {\n throw new RuntimeException('curl_init failed');\n }\n\n $options = array(\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_SSL_VERIFYPEER => true,\n CURLOPT_SSL_VERIFYHOST => 2,\n CURLOPT_CONNECTTIMEOUT => 5,\n CURLOPT_TIMEOUT => 15,\n );\n\n if ($caFile !== null) {\n $options[CURLOPT_CAINFO] = $caFile;\n }\n\n curl_setopt_array($handle, $options);\n $body = curl_exec($handle);\n $errorNo = curl_errno($handle);\n $error = curl_error($handle);\n $info = curl_getinfo($handle);\n curl_close($handle);\n\n if ($body === false) {\n throw new RuntimeException('cURL ' . $errorNo . ': ' . $error);\n }\n\n return array(\n 'httpStatus' => $info['http_code'],\n 'sslVerifyResult' => $info['ssl_verifyresult'],\n 'bodyBytes' => strlen($body),\n );\n}\n\n$url = $argv[1] ?? 'https://example.com/';\n$caFile = $argv[2] ?? null;\nvar_export(request($url, $caFile));</code></pre>\n<p>Запуск без второго аргумента использует CA store, который видит libcurl процесса:</p>\n<pre><code>php tls-check.php https://api.example.test/health\nphp tls-check.php https://api.example.test/health /absolute/path/to/ca-bundle.pem</code></pre>\n<p>Если доверие не строится, скрипт завершится исключением до HTTP-статуса. Если handshake прошёл, результат содержит HTTP-код и значение <code>ssl_verifyresult</code>, но это не заменяет проверку ответа API. Успешный <code>200</code> может означать только то, что TLS и HTTP для данного запроса завершились; он не доказывает правильность авторизации, данных или бизнес-операции.</p>\n<h2>Пошаговый runbook без обхода проверки</h2>\n<ol><li><strong>Зафиксируйте наблюдение.</strong> Сохраните полный URL без секретных query-параметров, hostname, порт, текст ошибки, время UTC и место запуска. Запишите, это CLI, PHP-FPM, worker или другой SAPI, а также имя контейнера или образа.</li><li><strong>Сравните инструменты.</strong> Выполните <code>curl --version</code>, <code>php --ini</code> и <code>php -i | grep -E 'curl.cainfo|openssl.cafile'</code> в том же окружении. Версия CLI не является версией libcurl внутри PHP.</li><li><strong>Посмотрите handshake.</strong> Запустите <code>curl -vS --connect-timeout 5 --max-time 15 https://api.example.test/health -o /dev/null</code>. Не публикуйте в тикете cookies, authorization-заголовки и чувствительные URL. Вывод показывает путь диагностики, но не заменяет тест приложения.</li><li><strong>Проверьте серверную цепочку.</strong> Для наблюдения используйте <code>openssl s_client -connect api.example.test:443 -servername api.example.test -showcerts < /dev/null</code>. Ключ <code>-servername</code> важен для виртуального хоста. Этот инструмент показывает выданные сертификаты; итоговое доверие всё равно оценивайте с тем CA bundle, который использует приложение.</li><li><strong>Разделите имя и маршрут.</strong> Сверьте hostname URL с SAN leaf-сертификата. Если подозреваете неправильный IP, повторите запрос через <code>--resolve</code>, сохранив исходное имя. Не заменяйте hostname на IP как «проверку»: это меняет проверяемую идентичность.</li><li><strong>Проверьте время и права.</strong> Снимите <code>date -u</code> внутри процесса или контейнера и убедитесь, что пользователь приложения может читать заявленный CA bundle. Ошибка доступа к файлу и отсутствие нужного корня выглядят по-разному на уровне причины, но обе проявляются до HTTP.</li><li><strong>Сделайте одно изменение.</strong> Если проблема в trust store, передайте подтверждённый CA bundle через <code>CURLOPT_CAINFO</code> или настройку <code>curl.cainfo</code>. Если проблема в intermediate, исправьте цепочку на TLS-сервере. Если проблема в SAN, перевыпустите сертификат с правильным именем. Не меняйте все слои одновременно.</li><li><strong>Повторите тем же SAPI.</strong> После изменения <code>php.ini</code> перезапустите PHP-FPM или другой долгоживущий процесс. Выполните безопасный health-запрос из того же контейнера и сохраните путь к bundle, версии и команду отката.</li><li><strong>Оставьте регрессию.</strong> Добавьте отдельные фикстуры для истёкшего сертификата, неизвестного issuer и неверного имени, если ваш тестовый стенд позволяет это сделать. Тест должен подтверждать отказ при включённой проверке, а не только успешный HTTP-ответ.</li></ol>\n<h2>Что считать исправлением, а что — маскировкой</h2>\n<p>Исправление имеет владельца. Неполная цепочка — задача владельца TLS endpoint; отсутствующий корпоративный root — задача поставки trust store; неверный SAN или SNI — задача конфигурации имени и виртуального хоста; неверные часы — задача среды; запрещённый алгоритм или версия — задача совместимости и политики. Смена CA bundle не лечит все четыре случая.</p>\n<p><code>curl -k</code> и <code>CURLOPT_SSL_VERIFYPEER => false</code> полезны только как короткий локальный эксперимент, когда нужно доказать, что дальше есть HTTP-ответ. Они не должны попадать в production-конфигурацию, общий helper или постоянный пример. Такой прогон отвечает на вопрос «можно ли продолжить без проверки», но не на вопрос «кому мы отправляем данные».</p>\n<p>Не скачивайте новый bundle по URL при каждом старте и не добавляйте в него любой сертификат, который встретился в handshake. CA bundle — часть поставки и политики доверия, а не кеш наблюдаемого ответа. Не считайте проверку сертификата доказательством прав доступа: после TLS остаются HTTP-аутентификация, авторизация, корректность endpoint и безопасность данных.</p>\n<h2>Ограничения и критерий готовности</h2>\n<p>Этот разбор не обещает диагностировать отзыв сертификата одинаково во всех TLS backend: поддержка CRL, OCSP и режима «best effort» зависит от клиента и политики. Он также не проверяет DNSSEC, корректность proxy, mutual TLS, pinning или авторизацию API. <code>openssl s_client</code> без явного набора параметров не является полным эквивалентом PHP cURL. Учебный скрипт не валидирует X.509 сам и не должен становиться security scanner.</p>\n<p>Считайте работу готовой, когда один и тот же PHP-SAPI в целевом окружении читает ожидаемый trust store, hostname совпадает с SAN, цепочка строится до согласованного CA, часы корректны, проверка peer и имени остаётся включённой, а безопасный запрос проходит без ошибки TLS. После этого отдельно проверяется HTTP-контракт. Вот почему «curl работает» — полезный сигнал, но не закрытие проверки: закрыть её можно только воспроизводимым результатом всех условий конкретного клиента.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc8446.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3</a> — handshake, сообщение <code>Certificate</code> и границы ответственности TLS.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc5280.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 5280: Internet X.509 Public Key Infrastructure Certificate and CRL Profile</a> — validity window, certification path, trust anchor и ограничения path validation.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9525.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9525: Service Identity in TLS</a> — reference identifiers, DNS-ID, IP-ID и правила сопоставления имени.</li><li><a href=\"https://curl.se/docs/sslcerts.html\" target=\"_blank\" rel=\"noopener noreferrer\">cURL: TLS Certificate Verification</a> — CA store, <code>--cacert</code>, <code>--insecure</code> и различия native/file-based хранилищ.</li><li><a href=\"https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYPEER.html\" target=\"_blank\" rel=\"noopener noreferrer\">libcurl: CURLOPT_SSL_VERIFYPEER</a>, <a href=\"https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html\" target=\"_blank\" rel=\"noopener noreferrer\">CURLOPT_SSL_VERIFYHOST</a> и <a href=\"https://curl.se/libcurl/c/CURLOPT_CAINFO.html\" target=\"_blank\" rel=\"noopener noreferrer\">CURLOPT_CAINFO</a> — отдельные настройки проверки peer, имени и CA bundle.</li><li><a href=\"https://www.php.net/manual/en/curl.configuration.php\" target=\"_blank\" rel=\"noopener noreferrer\">PHP manual: cURL configuration</a> — <code>curl.cainfo</code> и требование абсолютного пути.</li></ul>"
|
||
}
|