Ruthenium: Chromium для Android с поддержкой сертификата Минцифры

в 12:27, , рубрики: chromium, российские сайты, сертификат минцифры

Некоторые банки и государственные сервисы используют TLS-цепочки, которые заканчиваются корнем Russian Trusted Root CA. Яндекс Браузер и Атом умеют работать с такими цепочками. Chrome для Android показывает NET::ERR_CERT_AUTHORITY_INVALID, потому что этого корня нет в хранилище Chromium.

Я пользуюсь Chrome и не хотел заводить второй браузер для нескольких сайтов. Поэтому добавил корень в Chromium, ограничил его зонами .ru и .рф и собрал отдельный APK. Проект получил название Ruthenium.

Ruthenium: Chromium для Android с поддержкой сертификата Минцифры - 1

Почему Chrome отклоняет цепочку

Сайт передаёт браузеру свой сертификат и промежуточные сертификаты. Браузер проверяет имя сайта, сроки и подписи, а затем строит цепочку до доверенного корня.

Russian Trusted Root CA в хранилище Chromium не входит. Поэтому Chrome не может завершить проверку цепочки и отклоняет соединение.

Варианты без собственной сборки

Можно использовать браузер, в котором уже есть поддержка этой цепочки, но я не готов привыкать к новому дизайну

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

Почему появился Ruthenium

Мне был нужен привычный интерфейс Chrome, но без изменения пользовательского хранилища всей системы. Я взял стабильную Android-версию Chromium и добавил Russian Trusted Root CA только в хранилище браузера.

Собственного TLS-верификатора в Ruthenium нет. Chromium использует штатную проверку цепочки, но получает ещё один корень с ограничениями для .ru и .xn--p1ai. Проверка имени, срока действия, подписей и остальных свойств сертификатов не меняется.

Как устроен патч сертификата

Патч применяется к chrome/browser/net/profile_network_context_service.cc. Сертификат преобразуется из PEM в DER и встраивается в Android-сборку как массив байтов. Ниже показана вся C+±логика, которую добавляет патчер. Опущены только байты массива DER, полученные из сертификата механически.

// BEGIN Russian Trusted Root CA (Android-only, DNS-constrained)
#if BUILDFLAG(IS_ANDROID)
// Source: https://gu-st.ru/content/lending/russian_trusted_root_ca_pem.crt
// DER SHA-256: d26d2d0231b7c39f92cc738512ba54103519e4405d68b5bd703e9788ca8ecf31
constexpr uint8_t kRussianTrustedRootCaDer[] = {
    0x30, 0x82, /* ... полный DER сертификата ... */
};
#endif  // BUILDFLAG(IS_ANDROID)
// END Russian Trusted Root CA (Android-only, DNS-constrained)

Chromium получает не корень доверия без ограничений, а CertWithConstraints с двумя разрешёнными DNS-суффиксами:

// BEGIN Russian Trusted Root CA scoped trust
#if BUILDFLAG(IS_ANDROID)
  auto russian_trusted_root =
      cert_verifier::mojom::CertWithConstraints::New();
  russian_trusted_root->certificate = std::vector<uint8_t>(
      kRussianTrustedRootCaDer,
      kRussianTrustedRootCaDer + sizeof(kRussianTrustedRootCaDer));

  // A leading dot permits subdomains only. That covers registrable names below
  // the TLD while excluding unrelated DNS namespaces.
  russian_trusted_root->permitted_dns_names = {".ru", ".xn--p1ai"};

  additional_certificates->trust_anchors_with_additional_constraints.push_back(
      std::move(russian_trusted_root));
#endif  // BUILDFLAG(IS_ANDROID)
// END Russian Trusted Root CA scoped trust

Здесь .xn--p1ai — ASCII-представление .рф, а начальная точка разрешает имена ниже TLD, например bank.ru, но не саму строку ru и не имя bank.ru.example.com.

CertWithConstraints проверяет все DNS-имена из SAN (Subject Alternative Name), а не только hostname открываемой страницы. Если сертификат содержит bank.ru и example.com, цепочка через добавленный корень будет отвергнута даже при открытии bank.ru: одно из заявленных имён находится вне разрешённых зон.

У ограничения есть пробел: permitted_dns_names не применяется к SAN типа iPAddress. Если под этим корнем выпустить сертификат непосредственно на IP-адрес, текущий патч его область не сузит. Поэтому ограничение относится именно к DNS-именам, а не ко всем возможным идентификаторам сертификата.

Перед применением патча PEM строго декодируется, а SHA-256 полученного DER сверяется с lock-файлом. Задача сборки отклоняет неожиданную смену сертификата:

der = decode_pem(pem)
actual_sha256 = hashlib.sha256(der).hexdigest()
if actual_sha256 != EXPECTED_DER_SHA256:
    raise ValueError(
        f"unexpected certificate SHA-256: {actual_sha256}"
    )

Полная вставка вместе с DER-генератором лежит в scripts/patch_chromium.py, а ожидаемый отпечаток и оба официальных URL — в certificates/ministry-ca-lock.json.

Корень остаётся внутри Ruthenium. Системное хранилище Android не меняется, другие приложения его не получают, браузер не содержит дополнительного кода, кроме Chromium.

На текущей ревизии патчер меняет ровно четыре C+±файла:

  1. chrome/browser/net/profile_network_context_service.cc — добавляет ограниченный доверенный корень;

  2. components/signin/internal/identity_manager/primary_account_manager.cc — по умолчанию запрещает вход в аккаунт на уровне браузера;

  3. chrome/browser/ui/omnibox/omnibox_edit_model.cc — не вызывает недоступный без XR метод и возвращает пустой gfx::Image;

  4. chrome/browser/ui/webui/cr_components/searchbox/searchbox_handler.cc — заменяет ещё два недоступных без XR вызова обычной иконкой поиска.

Грабли сборки Chromium 151

Сам патч сертификата невелик. Больше времени заняла сборка Chromium 151 для Android с is_official_build=true: без нескольких дополнительных изменений чистый checkout не собирался в APK.

Официальная сборка без внешних профилей

is_official_build=true включает ThinLTO и другие оптимизации релизной сборки, а также ожидает внешние PGO-профили Chromium и V8. На 32-битном ARM после отключения Chrome PGO сборка пытается использовать отдельный профиль Android AFDO. Эти файлы не входят в закреплённые входы релиза, поэтому Ruthenium отключает оба внешних источника профилей:

is_debug = false
is_component_build = false
is_official_build = true

chrome_pgo_phase = 0
clang_use_default_sample_profile = false

ThinLTO при этом остаётся включённым; отключаются только оптимизации, результат которых зависел бы от незафиксированных внешних файлов.

Android без ненужного XR

Функции VR и AR в Ruthenium отключены:

enable_vr = false
enable_arcore = false
enable_openxr = false
enable_cardboard = false

В Chromium 151 есть несогласованность условий компиляции. Методы AutocompleteMatch::GetVectorIcon() и OmniboxAction::GetVectorIcon() объявлены на Android только при ENABLE_VR, но несколько мест вызова компилируются и без VR. Можно было вернуть enable_vr=true, но XR-код браузеру не нужен.

В chrome/browser/ui/omnibox/omnibox_edit_model.cc вызов закрыт тем же условием, что и объявление метода. На Android без VR функция возвращает пустое изображение. Это первое из трёх C+±изменений для сборки без XR:

#if !BUILDFLAG(IS_ANDROID) || BUILDFLAG(ENABLE_VR)
  bool is_starred_match = IsStarredMatch(match);
  const auto& vector_icon_type = match.GetVectorIcon(is_starred_match, turl);

  return controller_->client()->GetSizedIcon(vector_icon_type,
                                             vector_icon_color);
#else
  // Chromium 151 compiles this desktop-oriented implementation into Android
  // even though GetVectorIcon() is unavailable when XR is disabled.
  return gfx::Image();
#endif

Ещё два вызова находятся в chrome/browser/ui/webui/cr_components/searchbox/searchbox_handler.cc. Для них резервный вариант — обычная иконка поиска:

#if !BUILDFLAG(IS_ANDROID) || BUILDFLAG(ENABLE_VR)
  const bool is_bookmarked =
      bookmark_model->IsBookmarked(match.destination_url);
  // For starter pack suggestions, use template url to generate proper vector
  // icon.
  const TemplateURL* associated_keyword_turl =
      match.associated_keyword.empty()
          ? nullptr
          : turl_service->GetTemplateURLForKeyword(match.associated_keyword);
  mojom_match->icon_path = AutocompleteIconToResourceName(
      match.GetVectorIcon(is_bookmarked, associated_keyword_turl));
#else
  mojom_match->icon_path = kSearchIconResourceName;
#endif

И тот же guard нужен для иконок действий:

    if (action->GetIconImage().IsEmpty()) {
#if !BUILDFLAG(IS_ANDROID) || BUILDFLAG(ENABLE_VR)
      icon_path = AutocompleteIconToResourceName(action->GetVectorIcon());
#else
      icon_path = kSearchIconResourceName;
#endif
    } else {
      icon_path = webui::GetBitmapDataUrl(action->GetIconImage().AsBitmap());
    }

Отдельное приложение и вход в аккаунт

Ruthenium устанавливается рядом с Chrome под application ID app.ruthenium.browser и хранит свои данные отдельно. У приложения собственное название, иконка и страница About.

Вход в аккаунт на уровне браузера по умолчанию запрещён, а кнопка профиля (identity disc) скрыта. Сборка не настраивает Google Sync и интеграцию Google Chrome с системными аккаунтами Android. На авторизацию в аккаунте Google на сайтах это не влияет.

На C+±стороне это единственная продуктовая замена: значение prefs::kSigninAllowed по умолчанию меняется в primary_account_manager.cc:

-registry->RegisterBooleanPref(prefs::kSigninAllowed, true);
+registry->RegisterBooleanPref(prefs::kSigninAllowed, false);

Java-патч читает тот же Pref.SIGNIN_ALLOWED и скрывает кнопку профиля. Название, иконки и About-экран меняются в ресурсах Android и Java, а не в C++.

Встроенного автообновления пока нет. Новые APK нужно устанавливать вручную.

От исходников до APK

В репозитории указана ревизия Chromium и опубликованы патч, ресурсы приложения, параметры GN, сертификат с закреплённым SHA-256, конфигурация CI и тесты. Перед сборкой CI загружает указанную ревизию Chromium, получает сертификат с gu-st.ru и сверяет его с закреплённым отпечатком.

Входы сборки связаны с релизом через идентификатор релиза (release identity). В расчёт входят версия и ревизия Chromium, application ID, сертификат подписи, закреплённый корень Минцифры и полное содержимое build/args.gn и scripts/patch_chromium.py. Тег имеет вид android-<версия Chromium>-ca-<CA digest>-<release digest>: CA digest — первые 12 символов SHA-256 DER сертификата, а release digest — первые 12 символов SHA-256 всех перечисленных входов. Ротация корня меняет имя и создаёт отдельный релиз, не затрагивая релиз с уже опубликованными APK. Тег указывает на опубликованный снимок исходников; задача публикации сверяет его SHA-256 с маркером в коммите ветки main.

Рядом с каждым APK публикуются .sha256, результаты aapt2 dump badging и apksigner verify, список ABI, build-info и JSON с данными о происхождении сборки. Лицензия Chromium и публичный сертификат подписи общие для релиза. До публикации CI проверяет ZIP, ABI, application ID, label, подпись и метаданные. После публикации он скачивает затронутые файлы через новое соединение Tor и сравнивает размер и SHA-256. Эти проверки обнаруживают подмену файла, но не заменяют независимую воспроизводимую сборку.

Как установить

Большинству современных телефонов подходит вариант arm64-v8a.

  1. Откройте GitHub Releases и скачайте последний APK для arm64-v8a.

  2. Разрешите установку приложений из выбранного источника в настройках Android.

  3. Откройте APK и установите Ruthenium.

Ruthenium устанавливается рядом с Chrome. Его можно назначить браузером по умолчанию или открывать в нём только нужные ссылки.

Для старых ARM-устройств публикуется armeabi-v7a, для Android на Intel — x86_64. Узнать архитектуру устройства можно через ADB:

adb shell getprop ro.product.cpu.abi

Проверка загрузки

Рядом с APK лежит файл .sha256. В командах имя выбирается по шаблону, поэтому в каталоге должен находиться ровно один APK нужной архитектуры.

В Linux:

set -- Ruthenium-*-arm64-v8a.apk.sha256
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
  echo "Ожидался один checksum-файл" >&2
  exit 1
fi
sha256sum -c "$1"

В macOS:

set -- Ruthenium-*-arm64-v8a.apk.sha256
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
  echo "Ожидался один checksum-файл" >&2
  exit 1
fi
shasum -a 256 -c "$1"

В Windows нужно посчитать хеш и сравнить его со значением в .sha256:

$apk = @(Get-ChildItem .Ruthenium-*-arm64-v8a.apk)
if ($apk.Count -ne 1) { throw "Ожидался один APK" }
Get-FileHash $apk.FullName -Algorithm SHA256

Установить или обновить приложение через ADB можно командой:

set -- Ruthenium-*-arm64-v8a.apk
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
  echo "Ожидался один APK" >&2
  exit 1
fi
adb install -r "$1"

Итог

Мне был нужен Chromium, который открывает сайты с сертификатом Минцифры без установки сертификата в Root CA и Ruthenium решает эту задачу, не создавая дополнительных проблем

Браузер можно использовать как основной. На сайтах .ru и .рф он работает с расширенным RU Trust CA набором сертификатов. На остальных сайтах поведение не распространяется — Ruthenium ведёт себя как обычный Chromium.

Ruthenium не связан с Google, командой Chromium или Минцифры. Лицензии Chromium и сторонних компонентов сохранены. Сообщения об ошибках принимаются в issues репозитория.

Автор: aabaskin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js