18 KiB
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-сокет.
Живой путь входа — HostsScreen → HostsViewModel.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 — решение и реализация, затем ревью пунктов 2–3 |
| Кодер 1 | Gemini Flash 3.7 high | Пункты 2–3 §Scope |
Порядок обратный: пункт 1 — это выбор схемы авторизации под ограничения контракта хоста, а не механическая правка. Его делает кодер 2 и начинает с письменного решения в отчёте до кода.
Раунд 0 (кодер 2). Решение по пункту 1: какой redirect поддерживает хост, что из этого следует, какой вариант выбран. Письменно, до кода.
Раунд 1 (кодер 1). Тесты из §Required tests с фиксацией падения на base SHA, затем пункты 2–3.
Раунд 2 (кодер 2). Реализация пункта 1; независимая проверка пунктов 2–3 по §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.
- Схему БД, таймлайн, транспорт.
- Продуктовую логику заданий 08–10.
Anti-checklist
- Loopback оставлен, но в отчёте написано «переведено на custom scheme». Проверять надо, какой
redirect_uriфактически уходит вauthUrl, а не наличие метода-колбэка в файле. - Оба механизма колбэка остались в коде «на совместимость».
runInterruptibleдобавлен, но сокет не закрывается при отмене — поток освободится, слушатель нет.cleartextTrafficPermittedперенесён вdomain-config, аbase-configоставлен без явногоfalse.<certificates src="user"/>перенесён вdebug-overrides, но заодно продублирован вbase-config.- После правки trust-anchors соединение с закреплённым отпечатком перестало работать, и это «починили» возвратом
src="user". - Отмена диалога теперь не шлёт ничего, но пользователю не показано, что хост остался ждать ответа.
- Пустая строка заменена на строку
"cancel"/"deny"без подтверждения, что хост так её и понимает. Это та же выдумка, только длиннее. - Тест на отмену проверяет вызов метода репозитория, а не то, что уходит хосту.
- В отчёте нет вывода
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.kt — redirect_uri в собранном authUrl соответствует выбранному варианту; обязан падать на base SHA.
core/auth/LoopbackCancellationTest.kt — при сохранённом loopback отмена корутины закрывает сокет и освобождает поток; при удалённом loopback тест заменяется на проверку отсутствия ServerSocket в пути входа.
core/auth/AuthStatePersistenceTest.kt — state и 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 не удалось из-за отсутствия контракта.
Решение (по ветке "установить не удалось"):
- Loopback сохраняется, но приводится в безопасный вид:
- Вызов
accept()будет обернут вrunInterruptible(Dispatchers.IO). - В
finallyили черезinvokeOnCancellationбудет гарантированно закрыватьсяServerSocket, что освободит порт и поток при уходе пользователя. - Будет добавлен цикл с
continueдля игнорирования ошибочных или сторонних подключений (например, с невернымstate), чтобы они не прерывали процесс авторизации (resilience).
- Вызов
- Удаление мёртвого кода: Метод
handleAuthCallbackUriи соответствующийintent-filterдля схемыhermes://auth-callbackбудут полностью удалены, так как они не используются и создают путаницу. - В OPEN QUESTIONS будет зафиксировано требование к хосту: "Реализовать поддержку кастомной схемы
hermes://вredirect_uriдля эндпоинта/auth/native/authorize, чтобы можно было отказаться от локального веб-сервера на Android". Задание SEC-02 остается открытым.