hermes-android/agy-work/TASK-2026-08-24-06-auth-and-secrets.md

129 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Task 06: Авторизация, TLS и обращение с секретами (hermes-android)
**Repo:** `ochenstarik-ui/hermes-android`
**Assigned to:** Antigravity (режим оркестратора, два кодера)
**Priority:** CRITICAL (перехват кода авторизации, утечка секретов между хостами)
**Date:** 2026-08-24
**Base SHA:** результат задания 05 — указать фактический SHA при выдаче
**Зависимость:** задания 0105 приняты. Пункт 1 продолжает `SEC-01`, вторая половина которого была сознательно отложена из задания 01.
## Роли и протокол
| Роль | Модель | Что делает |
|---|---|---|
| Оркестратор | Antigravity | Разбивает работу, маршрутизирует, принимает результат |
| Кодер 1 | Gemini Flash 3.7 high | Реализация §Scope пунктов 16 |
| Кодер 2 | Gemini Pro high | Независимая проверка, доводка, **и полностью пункт 7** |
Пункт 7 (синхронизация контекста) отдан кодеру 2 намеренно: он требует проектного решения и оценки модели угроз, а не механической правки. Кодер 1 к нему не приступает.
**Раунд 1 (кодер 1).** Тесты из §Required tests для пунктов 16, фиксация падения на base SHA, затем код. Нерешённое — в `OPEN QUESTIONS`.
**Раунд 2 (кодер 2).** Независимое воспроизведение падения, проход §Anti-checklist с явными отметками, доводка пунктов 16, затем спецификация и реализация пункта 7. Findings по шкале.
**Раунд 3 (оркестратор).** Приёмка при фактическом выводе команд. Пункт 7 принимается только вместе с письменной спецификацией того, что именно передаётся между хостами.
## Проблема
**1. TLS к LAN-хосту не работает.** Задание 01 открыло cleartext-путь, но у локального хоста сертификат самоподписанный, trust anchors не настроены — вариант с `https://` по-прежнему не соединяется. Пользователь вынужден оставаться на http.
**2. PKCE через loopback-сокет (`SEC-02`).** `PkceLoopbackAuthManager.kt:35,41,62``ServerSocket(0, 1, 127.0.0.1)` доступен любому приложению на устройстве без разрешений. Первый подключившийся выигрывает единственный `accept()`: сторонняя программа срывает вход или пытается перехватить код. RFC 8252 для мобильных предписывает custom scheme или App Links. Плюс `accept()` — блокирующий вызов внутри `withContext(Dispatchers.IO)`, не реагирует на отмену: поток и слушающий сокет живут до трёх минут после ухода пользователя.
**3. Инъекция HTML в колбэк-страницу (`SEC-03`).** `PkceLoopbackAuthManager.kt:114,164` — параметр `error` подставляется в HTML без экранирования; страница отдаётся браузеру от имени `http://127.0.0.1:порт`.
**4. URL авторизации не проверяется (`SEC-04`).** `PkceLoopbackAuthManager.kt:48-54``validateUrlScheme` вызывается только при обмене кода; сам `authUrl` открывается как есть, весь OAuth-редирект может пойти по открытому http.
**5. Тикет в query-строке (`SEC-05`).** `JsonRpcGatewayClient.kt:96-98``"$wsUrl${sep}ticket=$ticket"` без `URLEncoder`. Тикет с `&`, `#`, `+`, `/` ломает URL; секрет попадает в логи сервера и прокси.
**6. Отказ Keystore роняет приложение (`SEC-07`).** `TokenVault.kt:24-38` превращает любое исключение в `SecurityException`, `HermesAppContainer.kt:31` создаёт хранилище лениво — первый доступ к токенам на проблемном устройстве роняет процесс без пути восстановления. Библиотека `security-crypto:1.1.0-alpha06` — альфа, помеченная в Jetpack как устаревшая. Токены пишутся через `apply()`: при падении процесса свежий refresh-token теряется.
Смежно (`NET-08`): обновление токена опирается на поиск подстроки «401» в тексте `IOException` (`HermesHostRuntime.kt:162,179,205`), причём в сообщение подклеивается тело ответа — «401» из произвольного текста сервера вызовет ложный разлогин. `NativeAuthTokens` не поддерживает `expires_in`.
**7. Пароли видны в снимках экрана (`SEC-08`).** `FLAG_SECURE` не выставляется нигде, хотя `ClarifyDialog.kt:104-115` принимает sudo-пароль и API-ключи. У поля нет password-типа клавиатуры — IME может сохранить введённое в словарь.
**8. Синхронизация контекста отправляет всю историю (`SEC-06`).** `UnifiedContextBuilder.kt:46-53` — при первом переключении `startIndex = 0`, уезжает весь таймлайн; если курсор не найден (`indexOfFirst == -1`), тоже 0, то есть полная пересылка повторно. Редактирование секретов — пять регулярок, не покрывающих AWS-ключи, блоки приватных ключей, строки подключения, содержимое `.env`. Вывод одного хоста подставляется в промпт другого как инструкции — канал кросс-хостовой prompt-инъекции.
## Scope
**1. Доверие сертификату хоста.**
Отпечаток сертификата фиксируется при сопряжении и закрепляется за хостом; последующие соединения проверяют его. Смена отпечатка — явный экран подтверждения с тем же весом, что и смена endpoint в `PairingPreviewDialog`. Где хранится отпечаток и что происходит при несовпадении — описать в отчёте до кода.
`OkHttpClient` конфигурируется на хост, а не один на всех: сейчас `HermesRestClient` создаётся единожды в контейнере.
**2. Редирект авторизации (`SEC-02`).**
Перевести на `hermes://auth-callback` (или App Link) с обработкой в `onNewIntent`, убрать loopback-сокет. Проверить, что `state` и PKCE-верификатор переживают пересоздание Activity — иначе вход будет ломаться при возврате из браузера на устройствах с активным killer'ом фоновых процессов.
Если контракт хоста жёстко требует loopback и не принимает custom scheme — это фиксируется в `OPEN QUESTIONS`, а loopback остаётся с двумя обязательными доработками: `accept()` в `runInterruptible` с закрытием сокета в `invokeOnCancellation`, и повторный `accept()` при первом подключении с неверным `state` вместо провала всего входа.
**3. Экранирование колбэк-страницы (`SEC-03`).** Значение параметра в HTML не подставлять вообще; отдавать статический текст по классу ошибки.
**4. Проверка схемы `authUrl` (`SEC-04`).** Прогонять через ту же проверку, что и обмен кода, до открытия браузера.
**5. Тикет вне query (`SEC-05`).** Передавать заголовком или через `Sec-WebSocket-Protocol`. Если контракт хоста принимает только query — кодировать значение и зафиксировать ограничение в `OPEN QUESTIONS` как требующее изменения на стороне хоста.
**6. Хранилище токенов и ошибки HTTP (`SEC-07`, `NET-08`).**
`SecurityException` при инициализации обрабатывать как «хранилище повреждено»: очистить, потребовать повторный вход, не падать. Секреты писать через `commit()`.
Ввести типизированный `HermesHttpException(code, body)`; сравнивать код, а не искать подстроку. Тело ответа в сообщение исключения не подклеивать.
Поддержать `expires_in` с пересчётом в абсолютное время.
Замену `security-crypto` на прямой `KeyStore` рассмотреть и обосновать выбор в отчёте; если решено остаться на библиотеке — зафиксировать версию и причину.
**7. Синхронизация контекста (`SEC-06`) — только кодер 2.**
Сначала письменная спецификация в отчёте: что именно передаётся на второй хост, в каком объёме, каким каналом, что пользователь видит перед передачей. Только после неё — код.
Требования: курсор по идентификатору сообщения с фолбэком на «последние N», а не на всю историю; передача отдельным системным сообщением, не склейкой с пользовательским промптом; вместо чёрного списка регулярок — явное решение о том, что вообще допускается пересылать; пользователю показывается объём и состав передаваемого до отправки.
Правку README о «синхронизации без полной пересылки» привести в соответствие с фактическим поведением.
## Do not change
- Формат payload сопряжения и `hermes://pair` — задание 07.
- Схему БД — задание 02; хранение отпечатка добавляется миграцией, схема версионируется по правилам задания 02.
- Транспортные гонки — задание 01, принято.
- Мёртвый слой — задание 10.
## Anti-checklist
1. Отпечаток проверяется при первом соединении, но при смене молча перезаписывается — TOFU без защиты от подмены. Проверить ветку несовпадения.
2. Custom scheme добавлен, а loopback-сокет остался в коде и всё ещё открывается. Проверить, что сокет больше не создаётся.
3. `state`/verifier хранятся в поле ViewModel — после пересоздания Activity вход ломается. Проверить сценарий возврата из браузера после убийства процесса.
4. Экранирование добавлено через ручную замену `<` и `>` — обойдётся через кавычки в атрибуте. Требование: значение не подставляется вовсе.
5. Тикет перенесён в заголовок, но старая ветка с query оставлена «на совместимость» и продолжает использоваться по умолчанию.
6. `SecurityException` перехвачен и проглочен: приложение не падает, но и не сообщает пользователю, что требуется повторный вход.
7. `HermesHttpException` введён, а в `HermesHostRuntime` остались `contains("401")`. Проверить поиском по файлу все три места.
8. `FLAG_SECURE` ставится на всю Activity навсегда — ломает штатные скриншоты во всём приложении. Требование: только на время показа диалога.
9. Пункт 7 сделан кодером 1 в обход §Роли, без спецификации.
10. Спецификация пункта 7 написана после кода и описывает то, что получилось, а не то, что решено.
11. В отчёте `green` для незапущенной команды (`AGENTS.md §3`).
## Definition of Done
- Соединение с хостом по `https` с самоподписанным сертификатом работает после подтверждения отпечатка; подмена сертификата даёт явный отказ и экран подтверждения.
- Вход не использует слушающий сокет на устройстве (или, при обоснованном исключении, отменяем и переживает чужое подключение).
- Параметр `error` не может внести разметку в колбэк-страницу.
- `authUrl` с http отклоняется, если cleartext для хоста не разрешён.
- Тикет не появляется в URL WebSocket-соединения.
- Отказ Keystore не роняет приложение; пользователю показывается требование повторного входа.
- Ложный «401» в теле ответа не приводит к очистке токенов.
- Sudo-диалог не попадает в снимки экрана и в список недавних задач.
- Второй хост получает объём контекста, соответствующий письменной спецификации; README ей не противоречит.
- Все существующие тесты зелёные, ни один не удалён и не ослаблен.
## Required tests
`core/auth/AuthRedirectTest.kt``state` и verifier переживают пересоздание; неверный `state` отклоняется.
`core/auth/CallbackEscapingTest.kt` — значение `error` не попадает в вывод. Обязан падать на base SHA.
`core/auth/AuthUrlSchemeTest.kt` — http-`authUrl` при `allowCleartext = false` отклонён. Обязан падать на base SHA.
`core/network/TicketTransportTest.kt` — тикет отсутствует в URL; спецсимволы не ломают соединение. Обязан падать на base SHA.
`core/security/VaultFailureTest.kt` — отказ инициализации даёт состояние «нужен повторный вход», не исключение.
`core/network/HttpErrorTypingTest.kt` — тело ответа со строкой «401» при коде 500 не вызывает очистку токенов. Обязан падать на base SHA.
`core/sync/ContextSyncPolicyTest.kt` — объём передаваемого соответствует спецификации; потерянный курсор не приводит к полной пересылке. Обязан падать на base SHA.
Проверка отпечатка и `FLAG_SECURE` — на устройстве; без устройства пункты `UNVERIFIED`.
## Required verification
```text
./gradlew --no-daemon testDebugUnitTest
./gradlew --no-daemon connectedDebugAndroidTest
./gradlew --no-daemon lint
./gradlew --no-daemon assembleDebug
```
## Result
`agents/antigravity/done/TASK-2026-08-24-06-auth-and-secrets.md` — разделы `## Кодер 1`, `## Кодер 2 (review + доработка + пункт 7)`, `## Спецификация синхронизации контекста`, `## Вердикт оркестратора`.
CRITICAL не закрывается по заявлению исполнителя: нужен независимый re-review с fix SHA.