MAATRIX / Блог / Старые Android перестали открывать сайт после смены корневого центра

Старые Android перестали открывать сайт после смены корневого центра

MAATRIX

В конце августа к нам обратился клиент с интернет-магазином: часть покупателей вдруг перестала открывать сайт, хотя в браузере на компьютере всё работало идеально. Через два часа разбора выяснилось, что дело не в сайте и не в хостинге — просто плановое обновление сертификата тихо переключило клиентов на другую цепочку доверия, а старые Android об этой цепочке ничего не знали.

Что сломалось

Жалобы начали приходить не пачкой, а ручейком — по несколько человек в день, всегда с одной и той же формулировкой: «сайт не открывается, пишет что-то про безопасность». В поддержке сначала решили, что это фишинговые предупреждения от какого-то расширения браузера, но количество обращений росло, а география была случайной.

Первая зацепка появилась, когда один из клиентов прислал скриншот: Chrome на телефоне показывал NET::ERR_CERT_AUTHORITY_INVALID и предлагал вернуться назад. Устройство — Samsung на Android 6.0.1, из тех, что до сих пор массово используются в качестве вторых телефонов и в регионах с медленным обновлением парка техники. При этом WebView в приложении того же клиента (у него было отдельное Android-приложение с встроенным браузером для оформления заказов) на новых телефонах открывал ту же страницу без единой ошибки.

Важная деталь, которая сразу сузила круг подозреваемых: ошибка возникала на уровне TLS-рукопожатия, ещё до того, как браузер успевал отправить HTTP-запрос. В логах nginx (access_log) таких соединений просто не было — сервер их не видел, потому что клиент обрывал сессию раньше. Это сразу исключило версию про блокировку по User-Agent или про правила в WAF: до анализа заголовков дело не доходило в принципе. О том, что происходит на этом этапе до первого байта ответа, подробно разобрано в статье про TLS-рукопожатие — стоит держать эту схему перед глазами, когда разбираете подобный инцидент.

Первые гипотезы, которые отбросили

Первым делом проверили самое очевидное — не истёк ли сертификат. openssl s_client -connect example.ru:443 -servername example.ru </dev/null 2>/dev/null | openssl x509 -noout -dates показал срок действия ещё на 75 дней вперёд. Сертификат свежий, только что перевыпущен автоматическим продлением Let's Encrypt — значит, дело не в просрочке.

Дальше проверили DNS: может, часть пользователей резолвит домен в старый IP, где висит битый конфиг или устаревший сертификат от предыдущего сервера. Прогнали dig +short example.ru A с нескольких точек через разные резолверы (Google DNS, Cloudflare DNS, локальный резолвер провайдера клиента) — везде один и тот же адрес, TTL нормальный, расхождений нет.

Третья гипотеза — сеть провайдера перехватывает трафик (transparent proxy у мобильного оператора, встречается в некоторых регионах). Её отбросили быстро: те же самые пользователи открывали другие HTTPS-сайты на своих телефонах без проблем, значит, ни MITM на уровне оператора, ни блокировка порта 443 ни при чём — проблема была специфична именно для этого домена.

Четвёртая гипотеза, самая близкая к правде, но всё же неверная: подумали на отсутствующий промежуточный сертификат — классическая ситуация, когда сервер отдаёт только конечный сертификат без цепочки до доверенного центра. Но openssl s_client ясно показывал: сервер отдаёт две записи в цепочке — конечный сертификат и промежуточный R3. Цепочка была полной с точки зрения количества звеньев. Значит, дело не в отсутствующем сертификате, а в том, каким именно промежуточным сертификатом подписана цепочка.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Что показали логи и трассировка

Чтобы увидеть проблему глазами старого Android, подняли эмулятор с образом Android 6.0 (API 23) в Android Studio и открыли сайт через встроенный браузер. Ошибка воспроизвелась один в один: SSLHandshakeException: Trust anchor for certification path not found. Это системная ошибка Android о том, что цепочка сертификатов не сходится ни к одному корню из системного хранилища доверенных центров устройства.

Дальше сравнили полную цепочку до и после последнего продления сертификата. У клиента в бэкапах сохранялась предыдущая версия fullchain.pem — файл, который certbot кладёт в /etc/letsencrypt/live/example.ru/fullchain.pem и на который в конфиге nginx смотрит директива ssl_certificate:

# старая цепочка (до обновления)
issuer: C=US, O=Let's Encrypt, CN=R3          -> подписан
issuer: C=US, O=Internet Security Research Group, CN=ISRG Root X1  -> но R3 в этой версии
                                                   был кросс-подписан DST Root CA X3

# новая цепочка (после автопродления)
issuer: C=US, O=Let's Encrypt, CN=R3          -> подписан напрямую
issuer: C=US, O=Internet Security Research Group, CN=ISRG Root X1

Разница в одной строке, но именно в ней всё дело. Let's Encrypt годами выпускал промежуточный сертификат R3 в двух вариантах: один подписан собственным корнем ISRG Root X1, второй — кросс-подписан старым корнем DST Root CA X3, который годами был встроен практически во все операционные системы, включая древние версии Android. У certbot есть флаг --preferred-chain, который явно указывает, какую из двух версий отдавать сервером по умолчанию.

Настоящая причина: сменился фактический корень доверия

ISRG Root X1 — собственный корневой сертификат Let's Encrypt — добавлен в системное хранилище доверенных центров Android начиная с версии 7.1.1 (API 25). На всех более старых версиях Android этого корня в хранилище просто нет физически, обновить его без обновления прошивки нельзя. Единственный способ для такого устройства проверить сертификат Let's Encrypt — пройти по цепочке через кросс-подписанный R3 до DST Root CA X3, который в старых Android присутствует.

У клиента в конфиге certbot когда-то давно был явно прописан preferred_chain = "DST Root CA X3" в файле /etc/letsencrypt/renewal/example.ru.conf — именно для совместимости со старым парком устройств части аудитории. Полгода назад сервер переезжал на новую VPS (миграция диска на более свежий тариф), и при переносе конфигурации /etc/letsencrypt скопировали не полностью: перенесли сертификаты и симлинки live/, но не файл renewal/example.ru.conf с параметрами, а его пересоздали заново командой certbot certonly без этого флага. До ближайшего планового продления (90-дневный цикл Let's Encrypt) старая цепочка ещё оставалась на диске и работала, а как только certbot автоматически перевыпустил сертификат по cron — он использовал уже свежий, «дефолтный» конфиг без preferred_chain, и подсунул короткую цепочку через ISRG Root X1 напрямую. Для современных браузеров и Android 7.1.1+ это совершенно нормально и даже предпочтительнее — короче цепочка, меньше данных при рукопожатии. Но для устройств со старой прошивкой это означало ту самую ошибку «Trust anchor not found»: до собственного корня Let's Encrypt они дотянуться не могут, а путь через привычный DST Root CA X3 сервер перестал предлагать.

Отдельно стоит сказать про саму историю с DST Root CA X3: этот корень формально истёк 30 сентября 2021 года, и в теории кросс-подписанная цепочка через него не должна была считаться валидной ни на каком устройстве. На практике же путь построения цепочки на многих старых версиях Android и в старых системных TLS-библиотеках не такой строгий, каким его описывают спецификации: устройство ищет любой путь до известного ему доверенного корня и в ряде случаев не проверяет срок действия самого корневого сертификата так же строго, как срок действия листового. Это наблюдаемое поведение конкретных версий, а не гарантия — и опираться на него как на постоянное решение нельзя, но именно оно объясняло, почему кросс-подписанная цепочка продолжала «вытягивать» старые телефоны ещё несколько лет после формального истечения DST Root CA X3.

Как подтвердили гипотезу

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

  1. Ручная сборка цепочки. Собрали цепочку вручную командой openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout, вывели список issuer/subject для каждого сертификата и убедились, что в проде сейчас именно короткая цепочка без DST Root CA X3.
  2. Тест через SSL Labs. Прогнали домен через публичный тест SSL-конфигурации — в разделе о путях построения цепочки для разных клиентов сервис явно показывает, для каких сочетаний ОС/браузера путь до доверенного корня не строится. Старые Android там были помечены как проблемные именно из-за отсутствия ISRG Root X1.
  3. Эмулятор + реальное устройство. Помимo эмулятора Android 6.0 нашли в офисе списанный телефон на Android 5.1 и открыли сайт через штатный браузер — ошибка воспроизвелась, что исключило артефакт именно эмулятора.

Все три способа указали на одно и то же: проблема не в самом сертификате и не в конфиге nginx, а именно в выборе промежуточного сертификата при выпуске.

Что изменили после инцидента

Первым делом вернули совместимость немедленно — перевыпустили сертификат с явным флагом:

certbot certonly --cert-name example.ru \
  --preferred-chain "DST Root CA X3" \
  -d example.ru -d www.example.ru

и перезагрузили nginx (--deploy-hook "systemctl reload nginx" в самом certbot, чтобы не забывать об этом руками). Через несколько минут после перевыпуска проверили эмулятором и старым телефоном — сайт открылся.

Дальше занялись тем, чтобы такая ситуация не повторилась при следующей миграции сервера:

  • Флаг preferred_chain вынесли из недокументированного «мы же помним» в отдельный файл deploy-notes.md в репозитории инфраструктуры клиента с явным комментарием, зачем он нужен и когда был добавлен.
  • В скрипт бэкапа сервера добавили резервное копирование не только /etc/letsencrypt/live и /etc/letsencrypt/archive, но и /etc/letsencrypt/renewal/*.conf — именно в этих файлах хранятся параметры вроде preferred_chain, authenticator, hooks. Без них certbot после переноса создаёт новый конфиг с настройками по умолчанию.
  • Добавили автоматическую проверку после каждого продления: скрипт в renewal-hooks/deploy/ считает количество сертификатов в цепочке и issuer каждого, сравнивает с эталоном и шлёт уведомление в мониторинг, если цепочка изменилась неожиданно.
  • Обсудили с клиентом долгосрочную стратегию: доля трафика со старых Android у него постепенно снижается год к году, и держать явную зависимость от истёкшего корня — временная мера, а не архитектурное решение. Альтернативные варианты на будущее: переход на CDN с собственным TLS-терминированием (там совместимость со старыми клиентами обычно решена на уровне провайдера) или коммерческий сертификат от центра с более широкой исторической поддержкой корней. Пока решили остаться на Let's Encrypt с явным флагом — дешевле и предсказуемее для текущего масштаба проекта.

Если у вас похожая ситуация — сервер только настраивается и вопрос совместимости с корневыми центрами ещё не вставал, проще сразу сверяться с инструкцией по настройке Let's Encrypt на VPS и заранее решить, нужна ли вам поддержка легаси-устройств. А если сертификаты уже настроены и вылезают смежные ошибки — по частым проблемам с Let's Encrypt есть отдельный разбор частых ошибок и решений.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

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

Как быстро проверить, какую цепочку сейчас отдаёт мой сервер?

Выполните openssl s_client -connect ваш-домен:443 -servername ваш-домен </dev/null 2>/dev/null | openssl x509 -noout -issuer для каждого сертификата в выводе, либо соберите цепочку через openssl crl2pkcs7 и openssl pkcs7 -print_certs, как описано выше. Issuer DST Root CA X3 у промежуточного сертификата означает длинную (совместимую) цепочку, ISRG Root X1 напрямую — короткую.

Стоит ли всем принудительно ставить preferred_chain "DST Root CA X3" на всякий случай?

Нет смысла, если у вас нет подтверждённой аудитории на устройствах старше Android 7.1.1 или в старых встроенных системах (POS-терминалы, IoT, старые Java-клиенты). Для обычного веб-проекта короткая цепочка через ISRG Root X1 — рекомендуемый по умолчанию вариант: она короче и не зависит от корня, который формально уже истёк.

Почему браузер на компьютере вообще не заметил проблему?

Современные ОС и браузеры давно содержат ISRG Root X1 в собственном хранилище доверенных корней и проверяют цепочку именно через него, не заглядывая в кросс-подписанный путь через DST Root CA X3. Проблема была специфична для устройств, у которых этого корня физически нет в прошивке.

Как узнать заранее, какая доля моей аудитории на старых Android?

Проверьте отчёты аналитики по версиям ОС (Google Analytics, Яндекс.Метрика — там есть разбивка по версии Android) за последние несколько месяцев. Если доля устройств до 7.1.1 заметна, длинную цепочку стоит держать сознательно, а не как случайный побочный эффект старого конфига.

Что делать, если сертификат выпускает не certbot, а панель управления или сторонний ACME-клиент?

Ищите аналогичную опцию выбора цепочки в документации конкретного клиента — большинство ACME-клиентов, работающих с Let's Encrypt, поддерживают выбор preferred_chain или эквивалентный параметр, потому что это часть протокола ACME, а не специфика certbot.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →