Files
progcode/editorial/agent-rewrites/008.json
T

8 lines
25 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": 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 -&gt; DNS и TCP-маршрут\n -&gt; ClientHello с SNI (если клиент его отправляет)\n -&gt; сертификат и цепочка от сервера\n -&gt; путь до доверенного CA + срок + политика\n -&gt; совпадение имени с сертификатом\n -&gt; обычный 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 =&gt; true</code> проверяет, что сертификат можно связать с доверенным CA. <code>CURLOPT_SSL_VERIFYHOST =&gt; 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>&lt;?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 =&gt; true,\n CURLOPT_SSL_VERIFYPEER =&gt; true,\n CURLOPT_SSL_VERIFYHOST =&gt; 2,\n CURLOPT_CONNECTTIMEOUT =&gt; 5,\n CURLOPT_TIMEOUT =&gt; 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' =&gt; $info['http_code'],\n 'sslVerifyResult' =&gt; $info['ssl_verifyresult'],\n 'bodyBytes' =&gt; 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 &lt; /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 =&gt; 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>"
}