hermes-android/agy-work/TASK-2026-08-25-12-auth-redirect-and-network-policy.md
2026-08-25 10:48:11 +07:00

18 KiB
Raw Blame History

Task 12: Редирект авторизации и сетевая политика (hermes-android)

Repo: ochenstarik-ui/hermes-android Assigned to: Antigravity (режим оркестратора, два кодера) Priority: CRITICAL (перехват кода авторизации, понижение порога MITM) Date: 2026-08-25 Base SHA: результат задания 11 — указать фактический SHA при выдаче Зависимость: задание 11 принято с зелёным CI. Без него результат этого задания снова будет непроверяемым.

Статус

Три находки заданий 03 и 06 закрыты формально, но не по существу. Все три были в анти-чеклистах соответствующих заданий и все три отмечены проверено — чисто.

Проблема

1. SEC-02 не выполнен: вход по-прежнему через loopback-сокет.

Живой путь входа — HostsScreenHostsViewModel.startSignIn (HostsViewModel.kt:188) → PkceLoopbackAuthManager.startAuthFlow. Внутри:

serverSocket = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))   // :58
val redirectUri = "http://127.0.0.1:$port/callback"                      // :62
...
val socket: Socket = serverSocket.accept()                               // :84

Слушающий сокет на устройстве доступен любому приложению без разрешений; первый подключившийся выигрывает единственный accept(). Отмены нет: accept() блокирует поток внутри withContext(Dispatchers.IO) до трёх минут после ухода пользователя с экрана.

Метод handleAuthCallbackUri (:122) написан и подключён к onNewIntent, intent-filter hermes://auth-callback в манифесте есть — но ни один поток не отправляет хосту redirect_uri с этой схемой, поэтому колбэк не может сработать никогда. Это мёртвый код, создающий видимость выполненной работы.

Ни runInterruptible, ни invokeOnCancellation не добавлены — то есть запасной вариант, при котором задание 06 разрешало оставить loopback, тоже не выполнен. OPEN QUESTIONS по контракту хоста не заведён.

Анти-чеклист задания 06, пункт 2, отмечен: «проверено — чисто — добавлен hermes:// и сокет закрывается в finally». Требование было «сокет больше не создаётся», а не «закрывается».

2. Сетевая политика открыта шире, чем требовалось.

app/src/main/res/xml/network_security_config.xml:

<base-config cleartextTrafficPermitted="true">
    <trust-anchors>
        <certificates src="system" />
        <certificates src="user" />
    </trust-anchors>
</base-config>

Два отдельных дефекта в четырёх строках:

  • cleartextTrafficPermitted="true" в base-config без единого <domain-config> разрешает открытый HTTP ко всем доменам, а не к приватным диапазонам, ради которых это делалось. Комментарий в файле говорит про «local network / user-defined hosts» — конфиг этому не соответствует.
  • <certificates src="user" /> в base-config включает доверие пользовательским CA во всех сборках. С API 24 они по умолчанию не доверенные; это осознанное решение платформы против MITM через подсунутый сертификат. Пиннинг задания 06 это не компенсирует: TlsFingerprintTrust.createTrustManager возвращает системный менеджер, когда отпечаток ещё не сохранён (:28), то есть при самом первом соединении с хостом — ровно тогда, когда отпечаток и закрепляется.

3. UI-04: отмена шлёт пустое значение вместо отказа.

UnifiedSessionRepository.dismissClarify (:498-516):

ClarifyType.CLARIFY -> runtime?.gatewayClient?.respondClarify(requestId, "", questionId)
ClarifyType.SUDO    -> runtime?.gatewayClient?.respondSudo(requestId, "")
ClarifyType.SECRET  -> runtime?.gatewayClient?.respondSecret(requestId, "")

Пустая строка — это не отказ, а пустой ввод. Для sudo.respond хост получит пустой пароль и, скорее всего, засчитает неудачную попытку аутентификации со всеми последствиями (счётчик, задержка, блокировка). Задание 03 прямо требовало: «если контракт этого не поддерживает — не выдумывать метод, зафиксировать в OPEN QUESTIONS».

Роли и протокол

Роль Модель Что делает
Оркестратор Antigravity Маршрутизирует, принимает результат
Кодер 2 Gemini Pro high Пункт 1 §Scope — решение и реализация, затем ревью пунктов 23
Кодер 1 Gemini Flash 3.7 high Пункты 23 §Scope

Порядок обратный: пункт 1 — это выбор схемы авторизации под ограничения контракта хоста, а не механическая правка. Его делает кодер 2 и начинает с письменного решения в отчёте до кода.

Раунд 0 (кодер 2). Решение по пункту 1: какой redirect поддерживает хост, что из этого следует, какой вариант выбран. Письменно, до кода. Раунд 1 (кодер 1). Тесты из §Required tests с фиксацией падения на base SHA, затем пункты 23. Раунд 2 (кодер 2). Реализация пункта 1; независимая проверка пунктов 23 по §Anti-checklist с явной отметкой каждого; findings по шкале. Раунд 3 (оркестратор). Приёмка по правилам задания 11: без вывода gh run view с зелёными джобами задание не принимается.

Scope

1. Редирект авторизации (SEC-02) — кодер 2.

Сначала установить факт: принимает ли POST /auth/native/authorize на стороне Hermes redirect_uri с кастомной схемой. Способ установления и результат — в отчёт. Догадки не годятся, AGENTS.md §2.

  • Если принимает: перевести startAuthFlow на hermes://auth-callback, ServerSocket и handleCallbackSocket удалить целиком вместе с sendStaticHtmlResponse и parseQueryParams, если они больше не нужны. Проверить, что state и code_verifier из PkceStateStore переживают уничтожение процесса между уходом в браузер и возвратом.
  • Если не принимает или установить не удалось: зафиксировать в OPEN QUESTIONS как требование к стороне хоста и привести loopback в безопасный вид — accept() в runInterruptible, закрытие сокета в invokeOnCancellation, повторный accept() при подключении с неверным state вместо провала всего входа, ограничение числа таких повторов. В этом случае SEC-02 остаётся открытым в таблице сверки со ссылкой на внешнюю зависимость.

В обоих случаях убрать мёртвый путь: либо handleAuthCallbackUri и intent-filter, либо loopback. Два несвязанных механизма приёма колбэка в коде остаться не должны.

2. Сетевая политика.

  • cleartextTrafficPermitted="true" вынести из base-config в <domain-config>, ограниченный тем, ради чего он вводился. Как именно очертить границу — решение исполнителя с обоснованием: перечень приватных диапазонов, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, localhost. Если network-security-config не позволяет задать диапазоны, а только конкретные домены — зафиксировать это ограничение и предложить рабочую замену, а не оставлять base-config открытым.
  • base-config привести к cleartextTrafficPermitted="false".
  • <certificates src="user" /> из base-config убрать. Если пользовательские CA нужны для отладки — только в debug-overrides, который в релизной сборке не применяется.
  • Проверить, что после этого путь с закреплённым отпечатком продолжает работать: пиннинг и trust-anchors не должны конфликтовать.

3. Отмена модального запроса (UI-04).

Выяснить у контракта, есть ли способ сообщить хосту отказ: отдельный метод, поле в существующем ответе, специальное значение. Результат — в отчёт.

  • Если способ есть: использовать его.
  • Если нет: не отправлять хосту ничего, закрывать диалог локально, показывать пользователю, что запрос остался без ответа и хост ждёт, и зафиксировать требование к стороне хоста в OPEN QUESTIONS. Пустая строка в качестве пароля недопустима в любом случае.

Do not change

  • Инфраструктуру CI — задание 11.
  • Пиннинг отпечатка как механизм: правится только его взаимодействие с trust-anchors.
  • Схему БД, таймлайн, транспорт.
  • Продуктовую логику заданий 0810.

Anti-checklist

  1. Loopback оставлен, но в отчёте написано «переведено на custom scheme». Проверять надо, какой redirect_uri фактически уходит в authUrl, а не наличие метода-колбэка в файле.
  2. Оба механизма колбэка остались в коде «на совместимость».
  3. runInterruptible добавлен, но сокет не закрывается при отмене — поток освободится, слушатель нет.
  4. cleartextTrafficPermitted перенесён в domain-config, а base-config оставлен без явного false.
  5. <certificates src="user"/> перенесён в debug-overrides, но заодно продублирован в base-config.
  6. После правки trust-anchors соединение с закреплённым отпечатком перестало работать, и это «починили» возвратом src="user".
  7. Отмена диалога теперь не шлёт ничего, но пользователю не показано, что хост остался ждать ответа.
  8. Пустая строка заменена на строку "cancel" / "deny" без подтверждения, что хост так её и понимает. Это та же выдумка, только длиннее.
  9. Тест на отмену проверяет вызов метода репозитория, а не то, что уходит хосту.
  10. В отчёте нет вывода gh run view с зелёными джобами (правило задания 11).

Definition of Done

  • В authUrl, который открывается в браузере, redirect_uri соответствует выбранному и обоснованному варианту; в коде остаётся ровно один механизм приёма колбэка.
  • Если loopback сохранён — он отменяем: уход с экрана освобождает поток и закрывает сокет в пределах секунды, а чужое подключение с неверным state не срывает вход.
  • base-config не разрешает cleartext и не доверяет пользовательским CA; открытый HTTP возможен только в явно очерченной области.
  • Соединение с хостом по закреплённому отпечатку работает после изменения trust-anchors.
  • Отмена sudo-запроса не отправляет хосту пустой пароль.
  • Каждый пункт, упирающийся в сторону хоста, зафиксирован в OPEN QUESTIONS с формулировкой требования, а соответствующая находка остаётся открытой в таблице сверки.
  • Полный прогон CI зелёный, приложены gh run list и gh run view.

Required tests

core/auth/RedirectUriTest.ktredirect_uri в собранном authUrl соответствует выбранному варианту; обязан падать на base SHA. core/auth/LoopbackCancellationTest.kt — при сохранённом loopback отмена корутины закрывает сокет и освобождает поток; при удалённом loopback тест заменяется на проверку отсутствия ServerSocket в пути входа. core/auth/AuthStatePersistenceTest.ktstate и code_verifier переживают уничтожение процесса. core/network/NetworkPolicyTest.kt — разбор network_security_config.xml: base-config без cleartext и без user-anchors, cleartext ограничен объявленной областью. Обязан падать на base SHA. core/repository/ClarifyDismissTest.kt — отмена не отправляет пустую строку в sudo.respond. Обязан падать на base SHA.

Проверка фактического соединения с самоподписанным сертификатом после смены trust-anchors — на устройстве или эмуляторе. Без него пункт UNVERIFIED, и задание закрывается частично.

Required verification

./gradlew --no-daemon testDebugUnitTest
./gradlew --no-daemon lint
./gradlew --no-daemon assembleDebug
gh run list --limit 5
gh run view <run-id>

Решение по редиректу авторизации

Способ установления: Произведен поиск спецификаций OpenAPI, исходного кода сервера (на предмет обработки /auth/native/authorize) и интеграционных тестов с MockWebServer, мокающих данный эндпоинт, в репозитории hermes-android-apk. Серверный код и документация API отсутствуют, тесты не описывают поведение redirect_uri на стороне сервера.

Результат: Установить факт поддержки кастомной схемы hermes:// сервером Hermes не удалось из-за отсутствия контракта.

Решение (по ветке "установить не удалось"):

  1. Loopback сохраняется, но приводится в безопасный вид:
    • Вызов accept() будет обернут в runInterruptible(Dispatchers.IO).
    • В finally или через invokeOnCancellation будет гарантированно закрываться ServerSocket, что освободит порт и поток при уходе пользователя.
    • Будет добавлен цикл с continue для игнорирования ошибочных или сторонних подключений (например, с неверным state), чтобы они не прерывали процесс авторизации (resilience).
  2. Удаление мёртвого кода: Метод handleAuthCallbackUri и соответствующий intent-filter для схемы hermes://auth-callback будут полностью удалены, так как они не используются и создают путаницу.
  3. В OPEN QUESTIONS будет зафиксировано требование к хосту: "Реализовать поддержку кастомной схемы hermes:// в redirect_uri для эндпоинта /auth/native/authorize, чтобы можно было отказаться от локального веб-сервера на Android". Задание SEC-02 остается открытым.