# 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`. Внутри:
```kotlin
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`:
```xml
```
Два отдельных дефекта в четырёх строках:
- `cleartextTrafficPermitted="true"` в `base-config` без единого `` разрешает открытый HTTP **ко всем доменам**, а не к приватным диапазонам, ради которых это делалось. Комментарий в файле говорит про «local network / user-defined hosts» — конфиг этому не соответствует.
- `` в `base-config` включает доверие пользовательским CA во всех сборках. С API 24 они по умолчанию не доверенные; это осознанное решение платформы против MITM через подсунутый сертификат. Пиннинг задания 06 это не компенсирует: `TlsFingerprintTrust.createTrustManager` возвращает системный менеджер, когда отпечаток ещё не сохранён (`:28`), то есть при самом первом соединении с хостом — ровно тогда, когда отпечаток и закрепляется.
**3. `UI-04`: отмена шлёт пустое значение вместо отказа.**
`UnifiedSessionRepository.dismissClarify` (`:498-516`):
```kotlin
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` в ``, ограниченный тем, ради чего он вводился. Как именно очертить границу — решение исполнителя с обоснованием: перечень приватных диапазонов, `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"`.
- `` из `base-config` убрать. Если пользовательские CA нужны для отладки — только в `debug-overrides`, который в релизной сборке не применяется.
- Проверить, что после этого путь с закреплённым отпечатком продолжает работать: пиннинг и trust-anchors не должны конфликтовать.
**3. Отмена модального запроса (`UI-04`).**
Выяснить у контракта, есть ли способ сообщить хосту отказ: отдельный метод, поле в существующем ответе, специальное значение. Результат — в отчёт.
- **Если способ есть:** использовать его.
- **Если нет:** не отправлять хосту ничего, закрывать диалог локально, показывать пользователю, что запрос остался без ответа и хост ждёт, и зафиксировать требование к стороне хоста в `OPEN QUESTIONS`. Пустая строка в качестве пароля недопустима в любом случае.
## Do not change
- Инфраструктуру CI — задание 11.
- Пиннинг отпечатка как механизм: правится только его взаимодействие с trust-anchors.
- Схему БД, таймлайн, транспорт.
- Продуктовую логику заданий 08–10.
## Anti-checklist
1. Loopback оставлен, но в отчёте написано «переведено на custom scheme». Проверять надо, какой `redirect_uri` фактически уходит в `authUrl`, а не наличие метода-колбэка в файле.
2. Оба механизма колбэка остались в коде «на совместимость».
3. `runInterruptible` добавлен, но сокет не закрывается при отмене — поток освободится, слушатель нет.
4. `cleartextTrafficPermitted` перенесён в `domain-config`, а `base-config` оставлен без явного `false`.
5. `` перенесён в `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.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
```text
./gradlew --no-daemon testDebugUnitTest
./gradlew --no-daemon lint
./gradlew --no-daemon assembleDebug
gh run list --limit 5
gh run view
```
## Решение по редиректу авторизации
**Способ установления:**
Произведен поиск спецификаций 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 остается открытым.