17 KiB
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
- Отпечаток проверяется при первом соединении, но при смене молча перезаписывается — TOFU без защиты от подмены. Проверить ветку несовпадения.
- Custom scheme добавлен, а loopback-сокет остался в коде и всё ещё открывается. Проверить, что сокет больше не создаётся.
state/verifier хранятся в поле ViewModel — после пересоздания Activity вход ломается. Проверить сценарий возврата из браузера после убийства процесса.- Экранирование добавлено через ручную замену
<и>— обойдётся через кавычки в атрибуте. Требование: значение не подставляется вовсе. - Тикет перенесён в заголовок, но старая ветка с query оставлена «на совместимость» и продолжает использоваться по умолчанию.
SecurityExceptionперехвачен и проглочен: приложение не падает, но и не сообщает пользователю, что требуется повторный вход.HermesHttpExceptionвведён, а вHermesHostRuntimeосталисьcontains("401"). Проверить поиском по файлу все три места.FLAG_SECUREставится на всю Activity навсегда — ломает штатные скриншоты во всём приложении. Требование: только на время показа диалога.- Пункт 7 сделан кодером 1 в обход §Роли, без спецификации.
- Спецификация пункта 7 написана после кода и описывает то, что получилось, а не то, что решено.
- В отчёте
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
./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.