DarkRiDDeR11 мин

PHP cURL. Как обновить устаревший CA bundle без отключения verification

PHPcURLTLSЭксплуатация

После смены сертификата у партнёра старый PHP-процесс начинает возвращать ошибку проверки, хотя браузер на той же машине открывает сайт. Если быстро поставить CURLOPT_SSL_VERIFYPEER в false, запросы оживут, но приложение сможет принять сертификат подменённого узла. Цена обхода — не только предупреждение в коде, а потеря проверки личности удалённой стороны.

В полевом случае я не обновляю первый попавшийся файл. Сначала доказываю, какой именно libcurl и какой trust store использует PHP. Затем проверяю кандидатный bundle на отдельном стенде, меняю путь контролируемо и повторяю тот же запрос с включённой проверкой.

Браузер не является контрольным клиентом

Браузер может брать корни из системного хранилища и обновлять их по своим правилам. PHP extension cURL может быть собран с другой TLS-библиотекой и читать файл, заданный при сборке, в curl.cainfo или через CURLOPT_CAINFO. Поэтому фраза «в Chrome работает» полезна как симптом, но не отвечает, что должен сделать PHP-процесс.

Я начинаю с минимального отчёта, который можно получить в закрытой административной команде. В нём нет паролей и ответа API: только версии и признак, читается ли кандидатный файл. Путь лучше не выводить в публичную страницу; достаточно оставить его в приватном журнале релиза.

function tlsEnvironmentReport($bundle)
{
    $version = curl_version();

    return array(
        'php' => PHP_VERSION,
        'libcurl' => $version['version'],
        'tls_library' => $version['ssl_version'],
        'curl_cainfo_set' => ini_get('curl.cainfo') !== '',
        'candidate_readable' => is_readable($bundle),
        'candidate_size' => is_readable($bundle) ? filesize($bundle) : null,
    );
}

Метод curl_version() возвращает данные о libcurl и SSL-библиотеке PHP-модуля. Этого достаточно, чтобы не сравнивать наугад PHP-FPM с командным curl. Если в отчёте bundle не читается, обновление сертификатов ещё не началось: сначала исправляю путь, владельца и права доступа для пользователя процесса.

Фиксирую точку, где выбирается CA file

В старом проекте CAfile иногда задают в трёх местах: значение по умолчанию curl.cainfo, явный CURLOPT_CAINFO в обёртке HTTP-клиента и настройки системы, с которыми собран libcurl. Я не меняю их одновременно. Иначе невозможно сказать, какая правка помогла и какое окружение останется на старом наборе после следующего деплоя.

Где найдено довериеКак проверяюБезопасное действиеПочему не делать иначе
Явный CURLOPT_CAINFOПоиск в HTTP-обёртке и лог пути на закрытом стендеЗаменить версионный файл в конфигурации этого клиентаИзменение php.ini не влияет на явную опцию
curl.cainfoСравнить ini_get() с загруженным php.iniУказать абсолютный путь и штатно перезапустить PHPОтносительный путь зависит от окружения
Системный default libcurlVerbose-след и документация сборки дистрибутиваОбновить системный пакет по процедуре платформыНельзя считать браузерный store тем же самым
Частный CA партнёраПодтвердить root у владельца APIДобавить подтверждённый root в отдельный управляемый bundleНе сохранять leaf из случайного TLS-ответа как корень
Контролируемое обновление CA bundle: определить активный источник доверия, подготовить версионный кандидат, проверить его на стенде, переключить конфигурацию, перезапустить PHP и повторить запрос с включённой проверкой.
Bundle меняется как конфигурационный артефакт: кандидат проверяется до переключения, а результат подтверждается тем же PHP-клиентом.

Готовлю новый bundle как артефакт релиза

Кандидатный файл беру из официального источника CA store или из доверенного пакета операционной системы. Его имя содержит версию или дату поставки, например ca-bundle-2018-08.pem. Такой путь лучше безымянного cacert.pem: при следующем отказе видно, какой набор проверялся, и можно откатить конфигурацию на прежний файл без ручного редактирования содержимого.

Перед переключением я проверяю не только наличие PEM-маркеров. Беру leaf и intermediate конкретного тестового сервера, которые были собраны через openssl s_client с правильным SNI, и строю цепочку против кандидата. Это не доказывает, что bundle подходит для всего интернета, но доказывает нужный нам сценарий и не требует выдумывать результат команды.

# leaf.pem и intermediate.pem получены из тестового TLS-ответа.
# ca-bundle-2018-08.pem — кандидат из утверждённого источника.
openssl verify \
  -purpose sslserver \
  -CAfile /opt/app/certs/ca-bundle-2018-08.pem \
  -untrusted ./intermediate.pem \
  ./leaf.pem

Если команда не строит цепочку, не объявляю новый bundle плохим без разбора. Возможно, сервер не прислал intermediate. Возможно, он использует частный CA, которого нет и не должно быть в публичном store. Возможно, для проверки был использован другой hostname без SNI. Каждая ветка требует собственного исправления; ни одна не требует отключить peer verification.

Переключаю PHP-клиент явно

Для независимой интеграции мне удобнее хранить абсолютный путь в конфигурации приложения и передавать его cURL. Тогда старый и новый bundle могут лежать рядом на время проверки, а код не читает путь из веб-запроса. Если в проекте выбран curl.cainfo, выполняю тот же принцип в php.ini и фиксирую перезапуск процесса в чек-листе релиза.

function partnerRequest($url, $bundle)
{
    if (!is_readable($bundle)) {
        throw new RuntimeException('Configured CA bundle is not readable');
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, array(
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CAINFO => $bundle,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
    ));

    $result = curl_exec($ch);
    $errno = curl_errno($ch);
    $error = curl_error($ch);
    curl_close($ch);

    if ($result === false) {
        throw new RuntimeException('Partner TLS request failed: ' . $errno . ' ' . $error);
    }

    return $result;
}

Значения timeout в примере — проектные, а не рецепт для всех API. Они нужны, чтобы демонстрационный запрос не висел бесконечно. Важнее другое: в рабочем коде нет ветки, где ошибка сертификата меняет CURLOPT_SSL_VERIFYPEER на false. Такой переключатель превращает сетевую аварию в скрытое изменение модели доверия.

Проверяю релиз тем же клиентом

После перезапуска я выполняю один заранее согласованный запрос с тем же PHP-SAPI, который обслуживает приложение. HTTP 200 сам по себе не является единственным критерием: сохраняю, что curl_exec() не вернул false, peer verification не была ослаблена и ответ соответствует контракту тестового endpoint. Для критичного API лучше выбрать безвредный health или read-only запрос, если владелец сервиса его предоставляет.

  1. Зафиксировать исходную ошибку, hostname, PHP-SAPI, curl_version() и текущий источник CAfile.
  2. Подготовить кандидатный bundle из утверждённого источника под отдельным версионным именем и проверить его чтение service-user.
  3. Снять leaf и intermediate тестового TLS-сервера через openssl s_client -servername и прогнать openssl verify с кандидатом.
  4. Переключить ровно один источник настройки: явный CURLOPT_CAINFO либо curl.cainfo, а не всё сразу.
  5. Штатно перезапустить PHP-процесс, если изменена глобальная конфигурация, и выполнить контролируемый PHP-запрос.
  6. Оставить в релизной заметке версию bundle, путь настройки, дату проверки и способ отката на предыдущий файл.

Что не является исправлением

  • Не ставлю CURLOPT_SSL_VERIFYPEER => false и не понижаю CURLOPT_SSL_VERIFYHOST. Официальная документация libcurl прямо указывает, что отключение проверки делает соединение небезопасным.
  • Не добавляю в доверенные корни leaf-сертификат, который сервер прислал сегодня. Leaf и intermediate могут быть заменены; доверие к ним имеет другой смысл, чем доверие к CA.
  • Не загружаю bundle из URL при каждом запуске приложения. Поставка файла должна проходить контролируемый релиз, иначе мы не знаем, какой root появился в доверии.
  • Не смешиваю проблему устаревшего CA store с ошибкой имени. Если URL не покрыт сертификатом, новый bundle не изменит правильный отказ.

Ограничения и следующий шаг

CA bundle не вылечит неправильно настроенный TLS-сервер: недостающий intermediate должен поправить владелец сервера. Он также не заменяет обновление старой версии PHP или libcurl, если в ней есть известное ограничение. Но контролируемый bundle даёт короткий и проверяемый путь для обычного случая: PHP знает, какому набору CA доверять, файл читается, тестовая цепочка строится, а production-запрос проходит без снятия защиты.

Итог

Устаревший trust store — это конфигурационная проблема, а не приглашение выключить TLS-проверку. Если зафиксировать активный клиент, проверить кандидатный bundle против реальной цепочки и переключить путь как часть релиза, то ошибка становится воспроизводимой. В следующий раз команда увидит версию файла и проверку, а не загадочный false в настройках cURL.

Проверяемые источники

  • PHP Manual: curl_version — версия libcurl и TLS-библиотеки, с которыми собран PHP-модуль
  • PHP Manual: cURL Runtime Configuration — директива curl.cainfo задаёт абсолютный путь по умолчанию для CURLOPT_CAINFO
  • PHP Manual: curl_error — текст последней ошибки текущего cURL-сеанса; его нужно читать до закрытия handle
  • PHP Manual: cURL constants — значения CURLOPT_CAINFO, CURLOPT_CAPATH, CURLOPT_SSL_VERIFYPEER и CURLOPT_SSL_VERIFYHOST
  • curl: SSL CA Certificates — проверка сертификата включена по умолчанию; CA store можно передать для конкретного соединения
  • libcurl: CURLOPT_CAINFO — путь к файлу доверенных CA для конкретного transfer
  • libcurl: CURLOPT_SSL_VERIFYPEER — выключение проверки не загружает CA и делает TLS-соединение небезопасным
  • OpenSSL 1.0.2: s_client — диагностика TLS-сервера, параметры -servername, -showcerts, -CAfile и -CApath
  • OpenSSL 1.0.2: verify — проверка цепочки, различие trusted CAfile и untrusted промежуточных сертификатов