SSL: certificate verify failed и unable to get local issuer certificate — как исправить

опубликовано
читать
≈ 8 мин
автор
среда и версия
Debian 12: curl 7.88, OpenSSL 3.0, Python 3.11, requests 2.28 (пакет Debian) и 2.34 (pip), git 2.39, pip 23.0; Node.js 22
содержание

certificate verify failed переводится как «проверка сертификата не прошла», а unable to get local issuer certificate — «не удалось найти у себя сертификат издателя». Программа подключилась к сайту по HTTPS, сервер показал сертификат, а клиент не смог подтвердить, что сертификат настоящий. Соединение обрывается до того, как уходят какие-либо данные: так HTTPS защищает от подмены сервера.

Сертификат проходит три проверки. Издатель должен быть в хранилище доверенных у клиента, срок действия не истёк, имя в сертификате совпадает с адресом. Какая из проверок не прошла, видно по хвосту сообщения:

СообщениеЧто не так
unable to get local issuer certificateиздателя нет в хранилище клиента: свой центр сертификации компании, прокси с проверкой трафика, сертификат Минцифры
self-signed certificateсертификат подписан сам собой, никакой центр за него не ручается
certificate has expiredсрок действия закончился — или на компьютере неправильные часы
Hostname mismatch, no alternative certificate subject name matchesсертификат выписан на другое имя: обращаются по IP или по другому домену
server certificate verification failed. CAfile: noneтак те же проблемы описывает git в Debian и Ubuntu
unable to verify the first certificateтак пишет Node.js: издатель неизвестен или сервер не прислал промежуточный сертификат

Ниже — как посмотреть сертификат сервера и как правильно исправить ошибку в curl, Python, git, pip и Node.js. Вывод снят на стенде: сервер api.local с сертификатом от собственного центра сертификации «Koddo Test Root CA» и клиент на Debian 12.

Веб / 01

Три проверки сертификата

Три проверки сертификатаСлева сертификат сервера: кому выдан — api.local, издатель — Koddo Test Root CA, действует до 2027 года. Справа хранилище клиента с корневыми сертификатами ISRG Root X1 и GlobalSign Root CA, сертификата Koddo Test Root CA там нет. Ниже три проверки и ошибки, если они не прошли: 01 издатель есть в хранилище, иначе unable to get local issuer certificate или self-signed certificate; 02 срок действия не истёк, иначе certificate has expired; 03 имя в адресе совпадает с сертификатом, иначе Hostname mismatch.сертификат серверакому: api.localиздатель: Koddo Test Root CAдо: 27.10.2027хранилище клиентаISRG Root X1GlobalSign Root CAKoddo Test Root CA — нет01 издатель в хранилищеunable to get local issuer certificate02 срок не истёкcertificate has expired03 имя совпадает с адресомHostname mismatch
Клиент доверяет сертификату, только если его издатель лежит в хранилище, срок не истёк и имя совпадает с адресом. На стенде не прошла первая проверка: Koddo Test Root CA клиенту незнаком.

Посмотри на сертификат сервера

Прежде чем что-то чинить, узнай, кому выдан сертификат, кто его подписал и до какого числа он действует:

openssl s_client -connect api.local:443 -servername api.local </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -enddate
subject=CN = api.local
issuer=CN = Koddo Test Root CA
notAfter=Oct 27 10:27:09 2027 GMT

Имя совпадает, срок в порядке, а издатель — центр сертификации, о котором клиент ничего не знает. Значит, нужно добавить его корневой сертификат в доверенные. Его выдаёт тот, кому принадлежит сервер: администратор компании, владелец API, отдел безопасности. На стенде это файл koddo-test-ca.crt.

curl: (60) SSL certificate problem

curl пишет причину после двоеточия:

curl https://api.local/status.json
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

Для разовой проверки передай корневой сертификат ключом --cacert:

curl --cacert koddo-test-ca.crt https://api.local/status.json
{"status": "ok"}

Другие причины выглядят так. Самоподписанный сертификат:

curl: (60) SSL certificate problem: self-signed certificate

Сертификат с истёкшим сроком — даже если издатель известен:

curl: (60) SSL certificate problem: certificate has expired

Обращение по IP, хотя сертификат выписан на имя api.local:

curl: (60) SSL: no alternative certificate subject name matches target host name '172.17.0.6'

Последние две ошибки сертификатом в доверенных не лечатся. Просроченный сертификат нужно перевыпустить на сервере, а обращаться — по тому имени, на которое он выписан.

Python: CERTIFICATE_VERIFY_FAILED

В requests та же ошибка спрятана в конец длинной цепочки:

python3 -c "import requests; requests.get('https://api.local/status.json')" 2>&1 | tail -1
requests.exceptions.SSLError: HTTPSConnectionPool(host='api.local', port=443): Max retries exceeded with url: /status.json (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:992)')))

Стандартный urllib пишет то же самое короче:

urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:992)>

Для одного запроса передай путь к корневому сертификату в verify:

import requests

print(requests.get('https://api.local/status.json', verify='koddo-test-ca.crt').json())
{'status': 'ok'}

Самоподписанный и просроченный сертификаты Python описывает теми же словами, что и curl: certificate verify failed: self-signed certificate и certificate verify failed: certificate has expired.

Добавить сертификат в систему

Чтобы не передавать файл каждой программе, корневой сертификат добавляют в системное хранилище. В Debian и Ubuntu это делается от root:

sudo cp koddo-test-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
Updating certificates in /etc/ssl/certs...
rehash: warning: skipping ca-certificates.crt,it does not contain exactly one certificate or CRL
1 added, 0 removed; done.
Running hooks in /etc/ca-certificates/update.d...
done.

Файл должен иметь расширение .crt, предупреждение про ca-certificates.crt безвредно. После этого без всяких ключей работают curl, git и Python из пакетов системы:

curl https://api.local/status.json
python3 -c "import requests; print(requests.get('https://api.local/status.json').json())"
{"status": "ok"}
{'status': 'ok'}

В Windows сертификат добавляют в «Доверенные корневые центры сертификации» через оснастку certmgr.msc, в macOS — в связку ключей «Система» в программе «Связка ключей».

Ловушка: в виртуальном окружении ошибка остаётся

requests, установленный через pip, не смотрит в системное хранилище: у него свой список корневых сертификатов из пакета certifi. Поэтому в виртуальном окружении ошибка никуда не делась:

.venv/bin/python -c "import requests, certifi; print(requests.__version__, certifi.where())"
.venv/bin/python -c "import requests; requests.get('https://api.local/status.json')" 2>&1 | tail -1
2.34.2 /home/dev/.venv/lib/python3.11/site-packages/certifi/cacert.pem
requests.exceptions.SSLError: HTTPSConnectionPool(host='api.local', port=443): Max retries exceeded with url: /status.json (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:992)')))

Два способа направить его к системному хранилищу. Переменная окружения REQUESTS_CA_BUNDLE с путём к общему файлу сертификатов:

REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt .venv/bin/python -c "import requests; print(requests.get('https://api.local/status.json').json())"
{'status': 'ok'}

Или пакет truststore (pip install truststore): он подключает системное хранилище ко всему Python — и в Linux, и в Windows, и в macOS. Две строки в начале программы, до импорта requests:

import truststore
truststore.inject_into_ssl()

import requests

print(requests.get("https://api.local/status.json").json())
{'status': 'ok'}

git: server certificate verification failed

git в Debian и Ubuntu собран с GnuTLS и описывает ту же проблему по-своему. Пока сертификата нет в системном хранилище:

git ls-remote https://api.local/repo.git
fatal: unable to access 'https://api.local/repo.git/': server certificate verification failed. CAfile: none CRLfile: none

git, собранный с OpenSSL, пишет как curl: SSL certificate problem: unable to get local issuer certificate. Помогает сертификат в системном хранилище, а если его нельзя туда положить — путь к нему в настройках git:

git config --global http.sslCAInfo ~/koddo-test-ca.crt && git ls-remote https://api.local/repo.git
36af205ce8ee3a9bbf60198da47f1b5e25235b7f	HEAD
36af205ce8ee3a9bbf60198da47f1b5e25235b7f	refs/heads/main

Если сертификат нужен только для одного сервера, настройку можно ограничить его адресом: git config --global http.https://api.local/.sslCAInfo ~/koddo-test-ca.crt.

pip: ошибка SSL при установке пакетов

Через прокси компании с проверкой трафика pip не может проверить сертификат PyPI и повторяет попытки, пока не сдастся. Корневой сертификат прокси передают ключом --cert или сохраняют в настройках pip:

pip install --cert koddo-test-ca.crt requests
pip config set global.cert ~/koddo-test-ca.crt

pip, который входит в Debian и Ubuntu, уже берёт системное хранилище, а свежие версии pip умеют подключать его через truststore.

Node.js и npm

fetch в Node.js выбрасывает общее fetch failed, а причина лежит в cause:

const res = await fetch('https://api.local/status.json')
console.log(await res.json())
TypeError: fetch failed
    at node:internal/deps/undici/undici:15456:13
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
    at async file:///home/node/app/status.mjs:1:13 {
  [cause]: Error: unable to verify the first certificate
      at TLSSocket.onConnectSecure (node:internal/tls/wrap:1787:34)
      at TLSSocket.emit (node:events:519:28)
      at TLSSocket._finishInit (node:internal/tls/wrap:1176:8)
      at ssl.onhandshakedone (node:internal/tls/wrap:962:12) {
    code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
  }

Node.js не читает системное хранилище по умолчанию, а дополнительные корневые сертификаты принимает из переменной NODE_EXTRA_CA_CERTS:

NODE_EXTRA_CA_CERTS=koddo-test-ca.crt node status.mjs
{ status: 'ok' }

У npm та же ошибка выглядит как UNABLE_TO_GET_ISSUER_CERT_LOCALLY или SELF_SIGNED_CERT_IN_CHAIN, а сертификат ему указывают командой npm config set cafile ~/koddo-test-ca.crt.

Сертификаты Минцифры

Часть российских сайтов работает на сертификатах национального удостоверяющего центра Минцифры. Его корневой сертификат, Russian Trusted Root CA, не входит ни в список Mozilla, на котором построены certifi и curl, ни в стандартные хранилища систем. Поэтому запрос к такому сайту из Python или curl заканчивается unable to get local issuer certificate, хотя в Яндекс Браузере сайт открывается. Корневой сертификат публикуется на Госуслугах (gosuslugi.ru/crt). Добавляй его в доверенные теми же способами, что выше, и только там, где программе действительно нужны такие сайты.

Почему не verify=False

Самый частый совет из интернета — выключить проверку: verify=False в Python, curl -k, NODE_TLS_REJECT_UNAUTHORIZED=0. Запрос пройдёт, а Python честно предупредит:

.venv/bin/python -c "import requests; print(requests.get('https://api.local/status.json', verify=False).json())"
/home/dev/.venv/lib/python3.11/site-packages/urllib3/connectionpool.py:1129: InsecureRequestWarning: Unverified HTTPS request is being made to host 'api.local'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings
  warnings.warn(
{'status': 'ok'}

Без проверки HTTPS шифрует трафик, но уже неважно, с кем: любой, кто встанет посередине — в общем Wi-Fi или на взломанном роутере, — подставит свой сертификат и прочитает пароли и токены. Для разовой проверки на своём стенде это допустимо. В коде, который уходит дальше твоего компьютера, — нет: добавь нужный корневой сертификат в доверенные.

Ошибки, на которых спотыкаются

В браузере сайт открывается, а curl и Python ругаются. Сервер не присылает промежуточный сертификат. Браузеры умеют докачать его сами, а curl, Python и Node.js — нет. Исправлять нужно на сервере: отдавать полную цепочку (в nginx — файл fullchain.pem вместо одного сертификата).

certificate has expired, хотя сертификат свежий. Проверь часы: date на компьютере с неправильной датой считает просроченным любой сертификат. Так бывает на виртуальных машинах после восстановления из снимка и на устройствах с севшей батарейкой часов.

Устаревший certifi. Список корневых сертификатов в certifi обновляется с новыми версиями пакета. Если в старом окружении перестали открываться сайты, которые работают в браузере, обнови его: pip install -U certifi.

Сертификат положили не туда. update-ca-certificates берёт только файлы с расширением .crt из /usr/local/share/ca-certificates/, а requests в виртуальном окружении системное хранилище вообще не видит — нужен REQUESTS_CA_BUNDLE или truststore.

Частые вопросы

Как перевести unable to get local issuer certificate

«Не удалось получить сертификат издателя из локального хранилища». Клиент не нашёл у себя центр сертификации, который подписал сертификат сервера, поэтому не может ему доверять.

Что значит SSL: CERTIFICATE_VERIFY_FAILED в Python

Python не смог проверить сертификат сервера. Причина написана сразу после certificate verify failed: — неизвестный издатель, самоподписанный сертификат, истёкший срок. Исправление зависит от причины: добавить корневой сертификат в доверенные, перевыпустить сертификат на сервере или поправить часы.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru