В начале октября я подключал своего бота к API мессенджера MAX, и запросы из aiohttp падали с ClientConnectorCertificateError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate.
Сервер подписан цепочкой Минцифры, которой нет в стандартных наборах корневых сертификатов. В статье про перенос бота в MAX решению достался один абзац, здесь разберу его подробно, с рисками и вариантами для httpx и requests. В моём боте этому сертификату доверяет один клиент в процессе, а системное хранилище, certifi и соединения с Telegram и Anthropic остались как были.
Что присылает сервер
openssl s_client -connect platform-api2.max.ru:443 -servername platform-api2.max.ru -showcerts </dev/null
Сертификатов в ответе два: *.max.ru (до 06.06.2027) и промежуточный Russian Trusted Sub CA (до 19.07.2029), который выпустил Russian Trusted Root CA Минцифры. Корень сервер не присылает, клиент должен найти его у себя. В наборах Debian, certifi и Windows его нет, отсюда и unable to get local issuer certificate.
Почему не verify=False и не корень в систему
|
Способ |
Область |
Минус |
|---|---|---|
|
|
клиент |
проверки нет совсем |
|
корень в образ, |
всё, что читает системное хранилище |
доверяют curl, aiohttp и urllib с настройками по умолчанию |
|
дописать в |
все, кто берёт certifi |
слетает при обновлении пакета |
|
|
почти весь процесс |
остальные клиенты теряют обычные корни или начинают доверять Минцифры |
С verify=False всё просто: токен бота MAX уходит в заголовке Authorization с каждым запросом, и без проверки его прочитает любой посредник на пути. С остальными способами вопрос в области действия. У промежуточного сертификата Минцифры нет расширения Name Constraints, и корень в общем хранилище, системном или certifi, позволит этой цепочке подтверждать любой домен для всех, кто это хранилище читает. В моём боте через certifi ходят клиенты Telegram (aiogram) и Anthropic (httpx).
Почему этим сертификатам на Хабре не доверяют и как ограничить корень на уровне всей системы, разобрано в статье о том, как не доверять сертификатам Минцифры. Для MAX описан и вариант с корнем в репозитории и своим набором (в разборе граблей Bot API), а близкий к моему подход, для HTTP-клиента банка, есть в статье про архив на Telegram Business API.
Ставить корень Минцифры в систему или в браузер ради одного API я не хотел. Задача была уже: чтобы цепочку признавал один клиент, который ходит на один хост, и больше никто в процессе.
Как устроено решение
Код из app/max/client.py:
_CA_FILE = Path(__file__).parent / "certs" / "russian_trusted_sub_ca.pem"def _ssl_context() -> ssl.SSLContext: """TLS context for MAX only.""" ctx = ssl.create_default_context() if _CA_FILE.exists(): ctx.load_verify_locations(cafile=str(_CA_FILE)) ctx.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN # the Sub CA is the anchor; its root is not shipped return ctx
Контекст отдаётся в aiohttp.TCPConnector(ssl=_ssl_context()) единственной сессии клиента MAX. create_default_context() оставляет системные корни и обязательную проверку сертификата и имени хоста, load_verify_locations добавляет промежуточный только в этот контекст. Глобальное состояние модуля ssl не меняется.
Флаг нужен потому, что OpenSSL по умолчанию строит цепочку до самоподписанного корня, а корня у меня нет. С VERIFY_X509_PARTIAL_CHAIN он может остановиться на любом сертификате из доверенного хранилища, даже не самоподписанном. Цепочка выходит из двух звеньев, *.max.ru и Russian Trusted Sub CA. Если файл не попадёт в образ, клиент упадёт с исходной ошибкой, доверять всем подряд он не начнёт.
Чем рискуешь
-
Флаг действует на весь контекст: якорем может стать любой сертификат из хранилища. Поэтому в файле ровно один сертификат, пачку промежуточных сюда класть не стоит.
-
Выше якоря ничего не проверяется. Если корень отзовёт промежуточный, клиент не узнает: Python по умолчанию не смотрит CRL и OCSP. Отзыв придётся отслеживать самому, поэтому срок и отпечаток записаны в README рядом с файлом.
-
Раз промежуточный подписывает сертификаты на любой домен, контекст не должен утекать в другие клиенты. Сессия клиента MAX ходит только в API и на адрес загрузки картинок, который выдаёт сам API. Жёстче будет передавать контекст в конкретный запрос:
session.get(url, ssl=ctx).
Почему якорь промежуточный, а не корень
Корень как якорь проверит цепочку целиком и поймает подменённый промежуточный, но доверяет всему, что подписал любой его промежуточный центр. Промежуточный доверяет только своим подписям, а с pathlen:0 новых центров под ним не появится. Цена в происхождении: файл взят из рукопожатия, и подлинность подтверждает только отпечаток. Мне узкое доверие показалось важнее.
То же в httpx, requests и urllib
Я проверил это на Python 3.13.7 с aiohttp 3.13.3, httpx 0.28.1, requests 2.32.5 и urllib. Без контекста все падают с той же ошибкой, с контекстом GET /me без токена отвечает 401: TLS прошёл, сервер отказал из-за токена.
ctx = _ssl_context()client = httpx.Client(verify=ctx) # httpx, у AsyncClient так жеurllib.request.urlopen(req, timeout=60, context=ctx) # urllib, так ходит мой скрипт публикацийclass ContextAdapter(HTTPAdapter): # requests: verify контекст не принимает def __init__(self, ctx, **kwargs): self._ctx = ctx super().__init__(**kwargs) def init_poolmanager(self, *args, **kwargs): kwargs["ssl_context"] = self._ctx return super().init_poolmanager(*args, **kwargs)session = requests.Session()session.mount("https://platform-api2.max.ru/", ContextAdapter(ctx)) # только этот адрес
Короче было бы verify="certs/russian_trusted_sub_ca.pem": на 3.13 это проходит, потому что urllib3 там сам ставит VERIFY_X509_PARTIAL_CHAIN, а на 3.12, судя по его коду, упадёт (не запускал). Адаптер от версии не зависит. В бою у меня только aiohttp и urllib.
Отпечаток и смена сертификата
Раз якорь взят из рукопожатия, главное не закрепить поддельный. 1 октября я сохранил второй сертификат из вывода -showcerts, запросил цепочку из двух независимых сетей (рабочая машина и VPS у хостера) и сравнил отпечатки:
openssl x509 -in russian_trusted_sub_ca.pem -noout -fingerprint -sha256# sha256 Fingerprint=21:55:78:50:36:C9:00:DB:B5:F1:BB:2A:15:69:C8:0C:55:59:5B:D6:BF:94:86:7A:29:BB:DD:BC:7D:88:A3:F2
Отпечатки совпали. Этот отпечаток и порядок замены я записал в README рядом с файлом.
Смена сертификата *.max.ru меня не заденет, пока новый подписан тем же промежуточным. Сменится промежуточный, и запросы к MAX начнут падать с той же ошибкой. Остальное приложение продолжит работать, ошибка уйдёт в лог. Тогда снова снять цепочку из двух сетей, сверить отпечатки, заменить файл и пересобрать образ.
Как проверял
Со 2 октября клиент работает в бою на вебхуках, с 17 октября через него уходят посты с картинками в канал MAX. Новый httpx-клиент без контекста, созданный после клиента с контекстом, к MAX по-прежнему не подключается, значит, глобальное состояние не задето. tls.get_verified_chain() (Python 3.13+) показывает два звена, SHA-256 второго начинается с 2155785036c900db, как отпечаток выше.
Где можно потерять время
Первая ловушка в версиях. В Python 3.13 create_default_context() уже включает VERIFY_X509_PARTIAL_CHAIN, а бот живёт в образе python:3.12-slim, где флага нет. Код без этой строки пройдёт локально на 3.13 и упадёт в контейнере.
Вторая в тексте ошибки. Когда файл подключён, а флага нет, сообщение меняется на unable to get issuer certificate, без слова local. Легко решить, что файл не тот, хотя дело во флаге.
У меня сертификат съел большую часть тех двух дней, что ушли на адаптер MAX. Обе ловушки я поймал уже потом, когда перепроверял решение для этой статьи на свежем Python: в бою бот работает с файлом и флагом вместе, и разницы я не замечал.
Что бы я сделал иначе
Контекст для клиента MAX я бы собрал с нуля, без системных корней:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # проверка сертификата и имени хоста включенаctx.load_verify_locations(cafile=str(_CA_FILE))ctx.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN
Он к MAX подключается, я проверил, и сертификат для хоста MAX от другого центра уже не примет. В нынешнем контексте системные корни остались, и сертификат от публичного центра там тоже пройдёт. Если MAX перейдёт на публичный центр, клиент сломается, и пусть я узнаю об этом из лога. Ещё я проверял бы отпечаток файла при старте приложения, чтобы подмену в репозитории видеть сразу.
ссылка на оригинал статьи https://habr.com/ru/articles/1102026/