BitPage

Peer certificate cannot be authenticated with known CA certificates: что значит эта строка и почему обновление ca-certificates не помогает

Автор:  ·   · 13 мин чтения

Короткий ответ: 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, и зря: пакет тут ни при чём.

Коротко

Откуда берётся эта строка и почему слово «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:

1
2
  case CURLE_SSL_CACERT:
    return "Peer certificate cannot be authenticated with known CA certificates";

То есть текст выдаётся при любом бэкенде — OpenSSL, NSS, GnuTLS, — и о причине отказа в нём нет ничего, кроме факта «проверка не прошла».

Дальше интереснее. Этот текст правился, и по формулировке однозначно определяется поколение клиента:

Текст для кода 60Версии curlДата релиза границы
...authenticated with known CA certificatesдо 7.21.6 включительно7.21.6 — 2011-04-22
...authenticated with given CA certificates7.21.7 – 7.61.17.21.7 — 2011-06-23
SSL peer certificate or SSH remote key was not OK7.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 это видно прямо:

1
2
3
4
5
  CURLE_OBSOLETE51,              /* 51 - NOT USED */
  ...
  CURLE_PEER_FAILED_VERIFICATION, /* 60 - peer's certificate or fingerprint
  ...
#define CURLE_SSL_CACERT CURLE_PEER_FAILED_VERIFICATION

Документация 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, типовой текст кода 60lib/strerror.c
Unrecognized Object Identifier.NSSlib/util/SECerrs.h, SEC_ERROR_UNRECOGNIZED_OID = SEC_ERROR_BASE + 143
SSL certificate problem: unable to get local issuer certificateOpenSSL, curl только пересказываетlib/vtls/openssl.c

Первая формулировка не содержит диагностики. Вторая — содержит, но словарём NSS: в NSS тексты ошибок лежат в таблице макросов ER3, и найти их можно по номеру.

1
2
ER3(SEC_ERROR_UNRECOGNIZED_OID, (SEC_ERROR_BASE + 143),
    "Unrecognized Object Identifier.")

Третья формулировка — самая полезная, и берётся она не из curl. В lib/vtls/openssl.c curl просто просит OpenSSL назвать причину и подставляет ответ в своё сообщение:

1
2
3
4
5
6
        lerr = SSL_get_verify_result(octx->ssl);
        if(lerr != X509_V_OK) {
          ssl_config->certverifyresult = lerr;
          failf(data, "SSL certificate problem: %s",
                X509_verify_cert_error_string(lerr));
        }

Отсюда самое полезное правило: словарь 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, несут дату конверсии первой же строкой.

1
2
3
4
5
$ head -4 /etc/pki/tls/certs/ca-bundle.crt
##
## Bundle of CA Root Certificates
##
## Certificate data from Mozilla as of: Wed Jan 17 04:12:05 2018 GMT

И когда нужный корень находится, системный файл переписывать не обязательно. 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 хранит все датированные срезы по предсказуемым адресам.

1
2
3
4
5
6
7
$ curl -sO https://curl.se/ca/cacert-2018-01-17.pem
$ curl -sO https://curl.se/ca/cacert-2026-09-25.pem
$ for f in cacert-2018-01-17.pem cacert-2026-09-25.pem; do
>   printf '%s bytes=%s certs=%s\n' "$f" "$(wc -c < $f)" "$(grep -c 'BEGIN CERTIFICATE' $f)"
> done
cacert-2018-01-17.pem bytes=223903 certs=138
cacert-2026-09-25.pem bytes=188900 certs=121

Файл 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 года:

1
2
3
4
5
$ curl -sO https://curl.se/ca/cacert-2026-03-19.pem
$ curl -sO https://curl.se/ca/cacert-2026-05-14.pem
$ grep -c 'BEGIN CERTIFICATE' cacert-2026-03-19.pem cacert-2026-05-14.pem
cacert-2026-03-19.pem:145
cacert-2026-05-14.pem:121

Дифф по отпечаткам: 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
subjectOU=GlobalSign ECC Root CA - R4, O=GlobalSign, CN=GlobalSignто же, дословно
notBefore / notAfterNov 13 2012 → Jan 19 2038то же, дословно
serial2A38A41C960A04DE42B228A50BE83498020203E57EF53F93FDA50921B2A6
SHA-256BE: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-файле годится только для чтения глазами.

1
$ openssl x509 -in root.pem -noout -fingerprint -sha256

Незнакомая цепочка из трёх сертификатов — обычно не виновник

Сервер может отдавать цепочку, чей верхний сертификат отсутствует в вашем бандле, и всё равно проверяться успешно — за счёт кросс-подписи. Это самый обидный ложный след: смотришь на цепочку, видишь незнакомый корень, делаешь вывод — и уходишь в неверную сторону.

Воспроизводится на публичной инфраструктуре, без стенда. Вот что сегодня отдаёт curl.se:

1
2
3
4
5
6
7
8
9
$ openssl s_client -connect curl.se:443 -servername curl.se -showcerts </dev/null 2>/dev/null \
>   | awk '/BEGIN CERT/,/END CERT/' | csplit -z -f c -b '%d.pem' - '/BEGIN CERT/' '{*}'
$ for f in c0.pem c1.pem c2.pem; do openssl x509 -in $f -noout -subject -issuer; done
subject=CN=curl.se
issuer=C=US, O=Let's Encrypt, CN=YR2
subject=C=US, O=Let's Encrypt, CN=YR2
issuer=C=US, O=ISRG, CN=Root YR
subject=C=US, O=ISRG, CN=Root YR
issuer=C=US, O=Internet Security Research Group, CN=ISRG Root X1

Три звена, и верхнее — ISRG Root YR, которого в срезе Mozilla нет вообще (там только ISRG Root X1 и ISRG Root X2). При этом ISRG Root YR сам подписан ISRG Root X1, который лежит в срезах как минимум с января 2018. Проверяю, взяв X1 единственным якорем:

1
2
3
4
5
6
7
$ openssl verify -CAfile anchor-x1.pem -untrusted <(cat c1.pem c2.pem) c0.pem
c0.pem: OK

$ openssl verify -CAfile anchor-x1.pem -untrusted c1.pem c0.pem
C=US, O=Let's Encrypt, CN=YR2
error 20 at 1 depth lookup: unable to get local issuer certificate
error c0.pem: verification failed

Разница между двумя запусками — только присутствие кросс-сертификата 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:

СертификатnotBeforenotAfterОграничение
CN=Russian Trusted Root CA2022-03-01 21:04:15 GMT2032-02-27 21:04:15 GMTCA:TRUE, pathlen:4
CN=Russian Trusted Sub CA2022-03-02 11:25:19 GMT2027-03-06 11:25:19 GMTCA: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, и это заметно любопытнее:

1
2
$ openssl x509 -in e-szigno-root-2017.pem -noout -subject -nameopt oid,sep_comma_plus_space
subject=2.5.4.6=HU, 2.5.4.7=Budapest, 2.5.4.10=Microsec Ltd., 2.5.4.97=VATHU-23584497, 2.5.4.3=e-Szigno Root CA 2017

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 сам по себе доказывает, что проверка включена. Если он приходит — искать надо не «почему не проверяет», а какой именно файл читается и чего в нём не хватает.

Что сделать, по шагам

  1. Зафиксировать клиента: curl -V — версия и TLS-бэкенд. Слово «known» в тексте ошибки означает 7.21.6 или старше.
  2. Найти файл, который читается: curl-config --ca, плюс проверить окружение на CURL_CA_BUNDLE, SSL_CERT_FILE, SSL_CERT_DIR.
  3. Посмотреть дату среза: head -4 <бандл> — строка ## Certificate data from Mozilla as of:.
  4. Снять цепочку с сервера: openssl s_client -connect host:443 -servername host -showcerts.
  5. Проверить цепочку против своего файла: openssl verify -CAfile <бандл> -untrusted <промежуточные> <лист> — получить настоящую причину вместо типового текста curl.
  6. Дочитать цепочку до конца: если верхний сертификат незнаком, проверить, кем он подписан, — кросс-подпись часто снимает вопрос.
  7. Искать нужный корень в актуальном срезе по отпечатку SHA-256, а не по имени.
  8. Подложить бандл точечно — --cacert или CURLOPT_CAINFO для одного клиента, а не переписывать системный файл.
  9. Убедиться, что 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.

#tls #curl #ca-bundle #openssl #nss

<< Previous Post

|

Next Post >>