Peer certificate cannot be authenticated with known CA certificates: что значит эта строка и почему обновление ca-certificates не помогает
Короткий ответ: Peer certificate cannot be authenticated with known CA certificates — это не описание причины, а типовой текст кода CURLE_SSL_CACERT (60) из таблицы ошибок самого curl, и слово «known» датирует клиента: так отвечают версии до 7.21.6 включительно (22 апреля 2011), в 7.21.7 текст сменился на «given», а с 7.62.0 код 60 отдаёт «SSL peer certificate or SSH remote key was not OK». Обновление ca-certificates помогает редко, потому что набор корней не только пополняется, но и сокращается: из 138 корней публичного среза Mozilla от 17 января 2018 в срезе от 25 сентября 2026 остались 53.
Пришёл я к этому из скучной ситуации: контейнер с системой эпохи RHEL/CentOS 6, внутри curl 7.19.7, собранный с NSS, часть исходящих HTTPS-интеграций внезапно перестала соединяться. Сертификаты на тех сайтах валидные — браузер открывает их без замечаний. В логе — ровно эта строка и ничего больше. Сначала я, как положено, полез обновлять ca-certificates, и зря: пакет тут ни при чём.
Коротко
- Решение: снять цепочку с сервера через
openssl s_client -showcertsи проверить её черезopenssl verify -CAfile <ваш бандл>— внятный текст ошибки даёт OpenSSL, а не curl. - Причина в большинстве случаев: нужного корня в старом бандле нет, потому что он появился позже, либо — что чаще и неожиданнее — старый корень из набора изъяли (85 корней из 138 за восемь лет).
- Альтернатива обновлению системного файла: подменить бандл точечно для одного клиента (
--cacert,CURLOPT_CAINFO,CURL_CA_BUNDLE), не трогая систему. - Грабли 1: проверять наличие корня
grep‘ом по имени. Корень может сохранить имя и срок действия, но быть другим сертификатом. - Грабли 2: обвинять незнакомую трёхзвенную цепочку от сервера. Она обычно верифицируется за счёт кросс-подписи, и виновник не в ней.
- Грабли 3: менять бандл в проекте, где стоит
CURLOPT_SSL_VERIFYPEER => false. При выключенной проверке сертификаты CA не загружаются вообще.
Откуда берётся эта строка и почему слово «known» датирует клиент
Строку печатает сам curl, а не TLS-библиотека: она лежит в его таблице текстов ошибок и от бэкенда не зависит. Я потратил на это лишний час, потому что искал не там — grep по исходникам NSS (lib/util/SECerrs.h, lib/ssl/SSLerrs.h, lib/util/secerr.h) и по бэкенду curl lib/vtls/nss.c в трёх разных версиях не дал ничего. Нашлось после того, как я скачал тарбол curl 7.19.7 целиком, — lib/strerror.c, строка 202:
| |
То есть текст выдаётся при любом бэкенде — OpenSSL, NSS, GnuTLS, — и о причине отказа в нём нет ничего, кроме факта «проверка не прошла».
Дальше интереснее. Этот текст правился, и по формулировке однозначно определяется поколение клиента:
| Текст для кода 60 | Версии curl | Дата релиза границы |
|---|---|---|
...authenticated with known CA certificates | до 7.21.6 включительно | 7.21.6 — 2011-04-22 |
...authenticated with given CA certificates | 7.21.7 – 7.61.1 | 7.21.7 — 2011-06-23 |
SSL peer certificate or SSH remote key was not OK | 7.62.0 и новее | 7.62.0 — 2018-10-31 |
Третья строка появилась не потому, что текст снова переписали, а потому что в 7.62.0 из strerror.c убрали ветку case CURLE_SSL_CACERT целиком: код 60 стал синонимом другой константы. В include/curl/curl.h версии 7.62.0 это видно прямо:
| |
Документация libcurl это подтверждает дословно: «This error code has been unified with CURLE_SSL_CACERT since 7.62.0. Its previous value was 51» (libcurl-errors(3)). До 7.62.0 это были два разных кода с разными смыслами: в curl.h версии 7.19.7 у 51 стоит комментарий peer's certificate or fingerprint, а у 60 — problem with the CA cert (path?).
Практический вывод: если в вашем логе слово «known», клиенту больше пятнадцати лет, и это первое, что надо починить, — не бандл.
Три текста на один отказ: кто именно даёт имя ошибке
Один и тот же по сути отказ приходит в трёх разных формулировках, и по формулировке сразу видно, кто её сочинил.
| Сообщение | Кто выдаёт | Где живёт |
|---|---|---|
Peer certificate cannot be authenticated with known/given CA certificates | сам curl, типовой текст кода 60 | lib/strerror.c |
Unrecognized Object Identifier. | NSS | lib/util/SECerrs.h, SEC_ERROR_UNRECOGNIZED_OID = SEC_ERROR_BASE + 143 |
SSL certificate problem: unable to get local issuer certificate | OpenSSL, curl только пересказывает | lib/vtls/openssl.c |
Первая формулировка не содержит диагностики. Вторая — содержит, но словарём NSS: в NSS тексты ошибок лежат в таблице макросов ER3, и найти их можно по номеру.
| |
Третья формулировка — самая полезная, и берётся она не из curl. В lib/vtls/openssl.c curl просто просит OpenSSL назвать причину и подставляет ответ в своё сообщение:
| |
Отсюда самое полезное правило: словарь NSS вы увидите только на старых машинах, потому что бэкенд выпилен — файл lib/vtls/nss.c отдаёт 200 на теге curl-8_2_1 и 404 на curl-8_3_0 (8.3.0 — 13 сентября 2023). На всём остальном нужно не вычитывать смысл из строки curl, а получить причину напрямую у OpenSSL.
Почему «обновить ca-certificates» не приносит нужный корень
Потому что пакет заменяет файл, а не расширяет его, и заменяет на набор, который за годы не только пополнился, но и сильно похудел. Плюс на системе, снятой с поддержки, репозиторий обычно либо уже мёртв, либо заморожен на той же дате, что и файл на диске, — тогда команда отрабатывает и не меняет ничего.
Понять, какой файл вообще читает ваш клиент, проще всего по его же заголовку: срезы Mozilla, которые раздаёт проект curl, несут дату конверсии первой же строкой.
| |
И когда нужный корень находится, системный файл переписывать не обязательно. curl документирует несколько независимых способов подсунуть свой бандл одному клиенту: ключ --cacert, опция CURLOPT_CAINFO, переменные CURL_CA_BUNDLE, SSL_CERT_FILE и SSL_CERT_DIR (SSL Certificates). Там же, к слову, лежит единственная разумная позиция по обходу проверки: «We strongly recommend this is avoided and that even if you end up doing this for experimentation or development, never skip verification in production».
Замена набора корней — это изъятие: из 138 корней 2018 года осталось 53
Между публичным срезом Mozilla от 17 января 2018 и срезом от 25 сентября 2026 из 138 корней уцелели 53 — остальные 85 изъяты, взамен добавлены 68. Это не оценка, это воспроизводится в три команды: проект curl хранит все датированные срезы по предсказуемым адресам.
| |
Файл 2026 года меньше файла 2018 года — и по числу корней, и по байтам. Это ровно то, что ломает интуицию «обновлюсь, и станет только лучше».
Из состава пропали корни, на которых держалась половина интернета:
| Корень | Срез 2018-01-17 | Срез 2026-09-25 |
|---|---|---|
DigiCert Global Root CA | есть | нет |
DigiCert Assured ID Root CA | есть | нет |
DigiCert High Assurance EV Root CA | есть | нет |
Baltimore CyberTrust Root | есть | нет |
GlobalSign Root CA (O=GlobalSign nv-sa, не путать с семейством R3/R5/R6) | есть | нет |
DST Root CA X3 | есть | нет |
Go Daddy Class 2 CA | есть | нет |
thawte Primary Root CA | есть | нет |
GeoTrust Global CA | есть | нет |
И это не архивная история восьмилетней давности. Самая массовая чистка случилась между двумя соседними ревизиями 2026 года:
| |
Дифф по отпечаткам: 24 корня изъяты, добавлено ноль. Среди изъятых — вся четвёрка AffirmTrust, три корня Trustwave, два QuoVadis, GTS Root R2, SwissSign Gold CA - G2, TeliaSonera Root CA v1, COMODO Certification Authority и те же три корня DigiCert. Счётчик по датам публикации проект curl ведёт сам, на странице CA Extract — там же, кстати, предупреждение, которое стоит прочитать до того, как считать этот файл эквивалентом браузерного хранилища: «Those constraints are thus not brought along in this cacert file!» (речь про name constraints, которые в PEM-срез не попадают).
Здесь я сам наступил на грабли, о которых следующий раздел. Первый дифф я считал по метке из PEM-файла и получил 83 изъятых, 66 добавленных и 55 уцелевших — вместо 85, 68 и 53. Разница не в округлении, а в методике.
Проверять корень по имени недостаточно
Корень может сохранить и имя, и срок действия, но быть другим сертификатом — grep по имени такую замену не видит. Ловится это только диффом по отпечатку.
Ошибка двухслойная. Слой первый: сравнивать по commonName из самого сертификата нельзя — у семейства GlobalSign CN равен просто GlobalSign, а различаются они в organizationalUnitName. В срезе 2018 года на 138 сертификатов приходится всего 126 различных CN, так что часть корней при таком сравнении просто сливается в одну запись. GlobalSign Root CA - R6 я на этом и потерял: дифф по CN сказал «в наборе нет», а grep -F по метке в файле — «есть».
Слой второй, который сравнение по имени не ловит в принципе:
| Поле | Срез 2018-01-17 | Срез 2026-09-25 |
|---|---|---|
| subject | OU=GlobalSign ECC Root CA - R4, O=GlobalSign, CN=GlobalSign | то же, дословно |
| notBefore / notAfter | Nov 13 2012 → Jan 19 2038 | то же, дословно |
| serial | 2A38A41C960A04DE42B228A50BE8349802 | 0203E57EF53F93FDA50921B2A6 |
| SHA-256 | BE:C9:49:11:C2:95:56:76:… | B0:85:D7:0B:96:4F:19:1A:… |
Имя то же, окно валидности то же — сертификат другой. Та же картина у Autoridad de Certificacion Firmaprofesional CIF A62634068: subject совпадает дословно, отпечаток меняется с 04:04:80:28:BF:1F:28:64:… на 57:DE:05:83:EF:D2:B2:6E:…. Ровно эти две пары и дают расхождение «55 уцелевших» против «53 уцелевших»: при сравнении по имени они считаются теми же корнями, и набор выглядит стабильнее, чем он есть.
Отсюда рабочее правило: идентичность корня — это отпечаток SHA-256, а метка в PEM-файле годится только для чтения глазами.
| |
Незнакомая цепочка из трёх сертификатов — обычно не виновник
Сервер может отдавать цепочку, чей верхний сертификат отсутствует в вашем бандле, и всё равно проверяться успешно — за счёт кросс-подписи. Это самый обидный ложный след: смотришь на цепочку, видишь незнакомый корень, делаешь вывод — и уходишь в неверную сторону.
Воспроизводится на публичной инфраструктуре, без стенда. Вот что сегодня отдаёт curl.se:
| |
Три звена, и верхнее — ISRG Root YR, которого в срезе Mozilla нет вообще (там только ISRG Root X1 и ISRG Root X2). При этом ISRG Root YR сам подписан ISRG Root X1, который лежит в срезах как минимум с января 2018. Проверяю, взяв X1 единственным якорем:
| |
Разница между двумя запусками — только присутствие кросс-сертификата c2.pem в цепочке. С ним старый якорь закрывает новую иерархию; без него не хватает одного звена, и OpenSSL честно говорит, какого именно: error 20 на глубине 1 — «нет локального издателя». Проверял на OpenSSL 3.5.7 (9 июня 2026), корень X1 — с notBefore Jun 4 2015, notAfter Jun 4 2035.
Так что незнакомый верхний сертификат в s_client -showcerts — не диагноз, а повод дочитать цепочку до конца. Диагноз ставит verify.
Корень, которого в публичном срезе нет
Корня Минцифры ни в одном срезе Mozilla нет: он раздаётся отдельно и ставится вручную, а у промежуточного сертификата срок кончается 6 марта 2027 года. Публичные параметры обоих файлов с gu-st.ru:
| Сертификат | notBefore | notAfter | Ограничение |
|---|---|---|---|
CN=Russian Trusted Root CA | 2022-03-01 21:04:15 GMT | 2032-02-27 21:04:15 GMT | CA:TRUE, pathlen:4 |
CN=Russian Trusted Sub CA | 2022-03-02 11:25:19 GMT | 2027-03-06 11:25:19 GMT | CA:TRUE, pathlen:0 |
Оба — sha256WithRSAEncryption, subject у обоих: C=RU, O=The Ministry of Digital Development and Communications. От 26 сентября 2026 до истечения Sub CA остаётся 161 день, и это стоит держать в календаре: у промежуточного сертификата с pathlen:0 замена ломает цепочку у всех, кто его прикрутил вручную.
Здесь же я снял с себя ещё одну гипотезу. Я предполагал, что Unrecognized Object Identifier. от NSS вызывают российские OID-атрибуты в subject (ОГРН, ИНН) — их старые библиотеки не знают. Посмотрел оба сертификата с -nameopt multiline: в subject только countryName, organizationName и commonName. Никаких дополнительных OID. Версия не подтвердилась.
А вот где такой атрибут реально появился — так это в самом бандле Mozilla, и это заметно любопытнее:
| |
2.5.4.97 — это organizationIdentifier. В срезе от 17 января 2018 корней с этим атрибутом ноль, в сегодняшнем — три (e-Szigno Root CA 2017, e-Szigno TLS Root CA 2023, AC RAIZ FNMT-RCM SERVIDORES SEGUROS), а в срезе от 19 марта 2026 их было четыре. И в текущем lib/util/secoidt.h у NSS таблица AVA-атрибутов содержит 21 запись, organizationIdentifier среди них нет — есть commonName, countryName, pseudonym, houseIdentifier, но не он. Утверждать, что именно это даёт SEC_ERROR_UNRECOGNIZED_OID, я не берусь: незнакомый атрибут библиотека может и просто напечатать числом. Но направление проверки понятное — старый клиент спотыкается не только на подписях, но и на полях, которых в его словаре не было.
Сначала проверьте, проверяет ли клиент вообще
Если в коде стоит CURLOPT_SSL_VERIFYPEER => false, подмена бандла не изменит ничего: при выключенной проверке CA-сертификаты не загружаются. Документация опции говорит об этом прямо: «When this option is disabled (set to zero), the CA certificates are not loaded and the peer certificate verification is skipped» (CURLOPT_SSL_VERIFYPEER). Строка CURLOPT_CAINFO рядом с выключенным VERIFYPEER — мёртвый код, и я такие пары видел не раз: кто-то однажды «полечил» ошибку выключением проверки, потом кто-то другой аккуратно дописал путь к своему бандлу, и оба изменения годами лежат рядом.
Обратное утверждение полезнее: код 60 сам по себе доказывает, что проверка включена. Если он приходит — искать надо не «почему не проверяет», а какой именно файл читается и чего в нём не хватает.
Что сделать, по шагам
- Зафиксировать клиента:
curl -V— версия и TLS-бэкенд. Слово «known» в тексте ошибки означает 7.21.6 или старше. - Найти файл, который читается:
curl-config --ca, плюс проверить окружение наCURL_CA_BUNDLE,SSL_CERT_FILE,SSL_CERT_DIR. - Посмотреть дату среза:
head -4 <бандл>— строка## Certificate data from Mozilla as of:. - Снять цепочку с сервера:
openssl s_client -connect host:443 -servername host -showcerts. - Проверить цепочку против своего файла:
openssl verify -CAfile <бандл> -untrusted <промежуточные> <лист>— получить настоящую причину вместо типового текста curl. - Дочитать цепочку до конца: если верхний сертификат незнаком, проверить, кем он подписан, — кросс-подпись часто снимает вопрос.
- Искать нужный корень в актуальном срезе по отпечатку SHA-256, а не по имени.
- Подложить бандл точечно —
--cacertилиCURLOPT_CAINFOдля одного клиента, а не переписывать системный файл. - Убедиться, что
VERIFYPEERвключён, — иначе шаги 5–8 ни на что не влияют.
Итог
Строка Peer certificate cannot be authenticated with known CA certificates несёт ровно один бит информации о причине и очень много информации о возрасте клиента: это curl не новее 7.21.6, то есть релиз до июня 2011 года. Диагностику надо получать у OpenSSL через verify, а к бандлу подходить с мыслью, что набор корней сокращается: 138 корней в 2018-м, 121 сегодня, 85 изъятий за восемь лет, 24 изъятия за одну ревизию весной 2026-го. И сверять корни по отпечатку — имя в PEM-файле не идентификатор.
Первоисточники: таблица ошибок libcurl, lib/strerror.c в curl 7.19.7, include/curl/curl.h в curl 7.62.0, таблица релизов curl, датированные срезы Mozilla, SSL Certificates, lib/util/SECerrs.h в NSS.