129 lines
17 KiB
Markdown
129 lines
17 KiB
Markdown
# Task 06: Авторизация, TLS и обращение с секретами (hermes-android)
|
||
|
||
**Repo:** `ochenstarik-ui/hermes-android`
|
||
**Assigned to:** Antigravity (режим оркестратора, два кодера)
|
||
**Priority:** CRITICAL (перехват кода авторизации, утечка секретов между хостами)
|
||
**Date:** 2026-08-24
|
||
**Base SHA:** результат задания 05 — указать фактический SHA при выдаче
|
||
**Зависимость:** задания 01–05 приняты. Пункт 1 продолжает `SEC-01`, вторая половина которого была сознательно отложена из задания 01.
|
||
|
||
## Роли и протокол
|
||
|
||
| Роль | Модель | Что делает |
|
||
|---|---|---|
|
||
| Оркестратор | Antigravity | Разбивает работу, маршрутизирует, принимает результат |
|
||
| Кодер 1 | Gemini Flash 3.7 high | Реализация §Scope пунктов 1–6 |
|
||
| Кодер 2 | Gemini Pro high | Независимая проверка, доводка, **и полностью пункт 7** |
|
||
|
||
Пункт 7 (синхронизация контекста) отдан кодеру 2 намеренно: он требует проектного решения и оценки модели угроз, а не механической правки. Кодер 1 к нему не приступает.
|
||
|
||
**Раунд 1 (кодер 1).** Тесты из §Required tests для пунктов 1–6, фиксация падения на base SHA, затем код. Нерешённое — в `OPEN QUESTIONS`.
|
||
**Раунд 2 (кодер 2).** Независимое воспроизведение падения, проход §Anti-checklist с явными отметками, доводка пунктов 1–6, затем спецификация и реализация пункта 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.
|