Цепочка сертификатов изнутри: почему браузер ругается, а curl молчит
Вы обновили сертификат, curl с того же сервера открывает сайт без единой жалобы, а в браузере — красный экран «сертификат недействителен» или «не удалось проверить цепочку». Логичный вывод «раз curl молчит, с сертификатом всё в порядке» здесь ошибочен: curl и браузер проверяют цепочку доверия по-разному, и молчание одного инструмента не означает, что сервер отдаёт клиентам всё необходимое. Разберём, что такое цепочка сертификатов на самом деле, почему её неполнота не всегда воспринимается как ошибка, и как проверить это на своём сервере, а не гадать по поведению одного клиента.
Содержание
Цепочка доверия: от вашего сертификата до корня
Сертификат, который видит браузер при заходе на ваш сайт (его называют leaf-сертификатом, или конечным, end-entity), сам по себе ничего не доказывает. Он подписан не «вообще кем-то», а конкретным центром сертификации — и чтобы клиент поверил подписи, ему нужно доверять этому центру. Проблема в том, что центры, которые выпускают конечные сертификаты пачками (Let's Encrypt, DigiCert, Sectigo и другие), почти никогда не подписывают их напрямую своим самым защищённым, корневым ключом. Вместо этого выстраивается цепочка:
leaf-сертификат (ваш домен)
подписан →
промежуточный сертификат (intermediate CA)
подписан →
корневой сертификат (root CA)
Корневой ключ хранится в максимально изолированных условиях (часто в офлайне, в HSM) и используется крайне редко — в основном чтобы подписать очередной промежуточный сертификат на несколько лет вперёд. Именно промежуточные сертификаты и делают повседневную работу: ими подписывают тысячи и миллионы конечных сертификатов для реальных сайтов. Это разумная защита: если скомпрометирован промежуточный ключ, его можно отозвать, не трогая корень, к которому и так почти никто не притрагивается.
Клиент — браузер, curl, почтовый сервер, что угодно — должен построить цепочку от вашего leaf-сертификата вверх до корня, который у него уже есть в списке доверенных, и проверить на каждом звене: подпись математически верна, срок действия не истёк, сертификат не отозван. Если цепочку выстроить не удаётся — клиент не может убедиться, что ваш сертификат вообще выпущен легитимным центром, и отказывается доверять соединению.
Почему сертификата сервера недостаточно
У вашего сервера почти наверняка есть только leaf-сертификат и, возможно, отдельно промежуточный — но клиенту нужна вся цепочка целиком, кроме корня (корень клиент и так должен иметь у себя локально, ему незачем присылать его с сервера). Смысл в том, что сервер обязан отдать TLS-клиенту все промежуточные звенья: не потому что клиент не может их найти сам, а потому что искать их — дополнительная работа, которую многие клиенты просто не делают.
Типичная ошибка — сконфигурировать веб-сервер так, чтобы он отдавал только cert.pem (сам сертификат домена), а не fullchain.pem (сертификат плюс промежуточные). Certbot от Let's Encrypt, например, кладёт рядом оба файла именно потому, что путаница возникает регулярно:
/etc/letsencrypt/live/example.com/cert.pem # только leaf
/etc/letsencrypt/live/example.com/fullchain.pem # leaf + intermediate(ы)
/etc/letsencrypt/live/example.com/chain.pem # только intermediate(ы)
В nginx правильная директива — именно fullchain.pem:
server {
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}
Порядок сертификатов внутри файла тоже важен: сначала leaf, затем промежуточные — от ближайшего к leaf до ближайшего к корню. Склеить их в обратном порядке или вставить корневой сертификат «до кучи» — распространённая ручная ошибка при самостоятельной сборке цепочки из отдельных файлов, полученных от CA не через автоматизацию. Мы отдельно разбирали похожий случай, когда из конфигурации убрали промежуточный сертификат и приложение перестало открываться — именно потому, что кто-то посчитал его «лишним файлом».
Важный нюанс: сервер не обязан (и не должен) присылать сам корневой сертификат. Некоторые CA-панели включают его в цепочку «на всякий случай» — это не ошибка per se, но и не обязательно: клиент всё равно проверяет корень по своей собственной копии, а не по той, что прислал сервер (иначе любой мог бы прислать поддельный «корень» и заявить, что ему доверяют).
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГде живут доверенные корни: у каждого клиента свой набор
Здесь начинаются расхождения. У «клиента TLS» нет единого списка доверенных корневых сертификатов — у каждой платформы и даже у каждого инструмента свой:
| Клиент / платформа | Откуда берёт доверенные корни |
|---|---|
| Chrome, Edge на Windows | Системное хранилище Windows (Schannel / CryptoAPI) |
| Firefox (любая ОС) | Собственное хранилище NSS, встроенное в сам браузер |
| Safari, curl на macOS | Связка ключей macOS (Keychain), через Secure Transport |
| curl на Linux (обычная сборка) | Системный пакет ca-certificates, файл вида /etc/ssl/certs/ca-certificates.crt, читается через OpenSSL |
| curl, собранный с другой TLS-библиотекой | Может использовать свой бандл или переменные окружения (CURL_CA_BUNDLE, SSL_CERT_FILE) |
| Мобильные приложения | Часто зашитый в приложение список (certificate pinning) — вообще не смотрит на системное хранилище |
Из этого уже видно, откуда возникает расхождение: Chrome на Windows и curl на том же Windows могут физически проверять цепочку через разный код (Chrome — через собственный движок BoringSSL, но с обращением к системному хранилищу за доверенными корнями; curl на Windows чаще собран со Schannel и целиком полагается на системное хранилище). Firefox вообще игнорирует и системное хранилище Windows, и ca-certificates на Linux — у него полностью автономный набор корней внутри самого браузера, который обновляется вместе с версией Firefox, а не с системой.
Это значит: сервер может годами «случайно работать» для всех, у кого нужный промежуточный сертификат уже есть локально (а он бывает предустановлен как часть корневого пакета или закэширован ОС), и внезапно перестать работать для клиента с чуть другим набором — например, для старого мобильного браузера, у которого набор корней не обновлялся, или для скрипта на сервере с урезанным пакетом ca-certificates.
AIA: как некоторые клиенты сами дособирают цепочку
Есть ещё один механизм, который сильно путает диагностику. В самом сертификате обычно присутствует расширение Authority Information Access (AIA) — по сути, URL, по которому можно скачать сертификат центра, выпустившего этот сертификат. Посмотреть его можно так:
openssl x509 -in cert.pem -noout -text | grep -A2 "Authority Information Access"
Типичный вывод содержит что-то вроде CA Issuers - URI:http://.../intermediate.crt. Смысл в том, что если клиент не получил от сервера промежуточный сертификат, но нашёл в leaf-сертификате ссылку на него через AIA — некоторые клиенты пойдут и скачают его сами, соберут цепочку и продолжат работу как ни в чём не бывало. Именно поэтому неполная цепочка иногда «работает».
Ключевое слово — «некоторые». Поведение по AIA-цепочкам исторически различается:
- Windows (Schannel/CryptoAPI) — активно дособирает цепочку через AIA и затем ещё и кэширует найденный промежуточный сертификат в системном хранилище на будущее. Это значит, что после первого же обращения к любому сайту с той же промежуточной CA у вас на машине этот сертификат уже есть локально — и дальнейшие проверки (в том числе через curl, собранный со Schannel) будут проходить успешно, даже если сервер отдаёт неполную цепочку.
- Классическая сборка curl с OpenSSL на Linux — как правило, AIA не подтягивает. Ей нужна вся цепочка либо от сервера, либо уже присутствующая в локальном хранилище. Если промежуточного сертификата нет ни там, ни там — будет честная ошибка проверки.
- Firefox — годами не занимался AIA-дозагрузкой вовсе, полагаясь строго на то, что прислал сервер, плюс собственное хранилище NSS. Из-за этого именно Firefox чаще всего «первым ловит» неполную цепочку, когда другие инструменты на той же машине её будто бы не замечают.
- macOS/Safari — тоже способны дособирать цепочку через AIA в некоторых случаях, используя системные механизмы проверки сертификатов.
Отсюда и типичная картина «в браузере ошибка, а curl молчит» задом наперёд не менее реальна: на Windows-машине, где Schannel уже когда-то подтянул и закэшировал нужный промежуточный сертификат, и curl, и Edge будут молчать, а вот Firefox на этой же машине — или curl, собранный статически с OpenSSL и без доступа к системному кэшу — упадёт с ошибкой построения цепочки. Первопричина в обоих случаях одна: сервер не отдаёт полную цепочку, а разные клиенты по-разному компенсируют этот недостаток.
Почему одна и та же цепочка то работает, то ломается
Если собрать картину воедино, несовпадение результатов между инструментами почти всегда объясняется одной из нескольких причин:
- Кэш промежуточного сертификата уже есть локально. Клиент когда-то (на этой же машине, через любой другой сайт с той же CA) уже скачал нужный промежуточный сертификат по AIA и сохранил его в системное хранилище. Теперь ему не важно, что ваш сервер его не присылает.
- Клиент умеет AIA, а второй — нет. См. таблицу поведения выше: разница между Schannel и «голым» OpenSSL здесь принципиальна.
- У клиентов разные наборы доверенных корней. Бывает так, что старый корневой сертификат ещё валиден и доверен в одном хранилище, но уже удалён или помечен как недоверенный в другом — типичная ситуация при смене корня у CA или при истечении срока действия старого кросс-подписанного корня (так было при истечении срока действия у одного из старых корней, которым исторически кросс-подписывались сертификаты Let's Encrypt — старые устройства, не получившие обновлений, начали массово отваливаться, хотя современные браузеры на актуальных ОС проблемы не замечали).
- Разные версии одного и того же клиента. Старая версия curl или системной библиотеки TLS может не знать о новом корне вовсе, если он появился позже сборки.
- Дополнительные проверки поверх цепочки. Браузеры дополнительно проверяют статус отзыва (через OCSP или CRL), HSTS, Certificate Transparency — любая из этих проверок может дать ошибку, внешне похожую на «проблему с цепочкой», хотя сама цепочка выстраивается нормально. С отзывом сертификатов есть и обратная, не менее коварная проблема — мы разбирали случай, когда OCSP-ответчик лёг и сайт открывался девять секунд вместо мгновенного отказа или мгновенного успеха.
Практический вывод: то, что curl или один конкретный браузер «не жалуется», ничего не говорит о корректности настройки сервера. Единственный надёжный способ — явно проверить, что сервер отдаёт, независимо от локального кэша и AIA-магии клиента.
Как проверить и починить цепочку на своём сервере
Первый и самый честный инструмент — openssl s_client, потому что по умолчанию он не подтягивает ничего лишнего и не использует системный кэш так агрессивно, как браузер:
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
В выводе будет пронумерованный список сертификатов (Certificate chain, 0 s:... i:..., 1 s:... i:... и так далее) — это именно то, что реально прислал сервер, byte in byte. Если в списке только один сертификат (индекс 0) — сервер отдаёт неполную цепочку, и вам повезло только благодаря клиентскому кэшу или AIA.
Дальше можно явно проверить цепочку без доверия к локальному кэшу, указав корень вручную:
openssl verify -CAfile root-ca.pem -untrusted chain-only.pem cert.pem
Если команда выдаёт OK — цепочка математически валидна. Если unable to get local issuer certificate — не хватает промежуточного звена именно в том, что отдаёт сервер.
Ещё один быстрый способ — сравнить поведение самого curl в «строгом» режиме и с явным указанием доверенного бандла:
curl -v https://example.com/ 2>&1 | grep -A5 "Server certificate"
curl --cacert /etc/ssl/certs/ca-certificates.crt -v https://example.com/
Если curl без дополнительных флагов молчит, а с явным --cacert, ограниченным только официальными корнями, начинает ругаться — это почти наверняка означает, что где-то в системе уже закэширован нужный промежуточный сертификат.
Для внешней, независимой от локального кэша проверки полезны сервисы вроде SSL Labs (ssllabs.com/ssltest) или локальный testssl.sh — они запускают проверку с чистого состояния и явно указывают, если сервер не отдаёт полную цепочку («Chain issues: incomplete»). Мы также писали отдельно про то, что вообще происходит на TLS-рукопожатии до первого байта данных — это помогает быстрее находить, на каком шаге ломается конкретный клиент.
Сама починка обычно сводится к трём вещам:
- Убедиться, что веб-сервер отдаёт
fullchain.pem, а неcert.pem— для nginx, Apache, HAProxy, любых обратных прокси. - Проверить порядок сертификатов в файле цепочки: leaf первым, затем промежуточные снизу вверх к корню, без самого корня.
- После смены сертификата (в том числе автопродления через certbot/cert-manager) перечитать конфигурацию сервиса, а не просто положить новый файл на диск — старый процесс держит открытым файловый дескриптор на прежний сертификат до явного
reload.
Если сертификаты продлеваются автоматически, стоит держать отдельный мониторинг именно цепочки, а не только даты истечения leaf-сертификата — можно добавить проверку в систему мониторинга сертификатов и доменов, которая раз в сутки прогоняет openssl s_client -showcerts по всем продакшен-доменам и сверяет число звеньев в цепочке, а не только оставшийся срок действия.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Если сайт открывается в Chrome, значит цепочка точно в порядке?
Нет. Chrome на Windows использует системное хранилище, которое может кэшировать промежуточные сертификаты, полученные через AIA при заходе на другие сайты. Проверяйте цепочку openssl s_client -showcerts, а не поведением одного браузера.
Обязательно ли включать в конфигурацию сервера корневой сертификат?
Нет, и обычно не нужно — клиент проверяет корень по своей локальной копии, а не по той, что прислал сервер. Присылать нужно leaf и промежуточные сертификаты, корень — по желанию CA-панели, без функционального эффекта.
Почему после смены сертификата сайт «отвалился» не у всех сразу?
Классический сценарий — старый процесс nginx или Apache держит в памяти прежнюю (возможно, неполную) цепочку до reload, а часть клиентов тем временем уже получила закэшированный ранее рабочий промежуточный сертификат через AIA — отсюда неравномерность жалоб.
Можно ли доверять результату одного онлайн-чекера сертификатов?
В целом да, если он явно проверяет именно цепочку «с нуля», без обращения к локальному кэшу — так работает большинство независимых онлайн-сканеров. Но для полной уверенности лучше дополнительно прогнать openssl s_client -showcerts со своей машины, особенно если внутренние клиенты (например, серверные скрипты) используют другой TLS-стек, чем браузер, которым вы проверяли сайт вручную.
Что делать, если у разных клиентов расходится набор доверенных корней и мы ничего не контролируем?
Держать на сервере полную и актуальную цепочку до общепризнанного корня, избегать длительного использования кросс-подписанных или устаревающих промежуточных сертификатов и следить за анонсами CA о смене корней — это единственная часть уравнения, которую вы реально контролируете.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →