Запись 59. Мобильная зрелость и криптографическая гигиена: как мы устранили сбой синхронизации ключей и очистили интерфейс Android-клиента
⏱️ Время: 1 октября 2026, 15:00 — 17:30 (~2.5 часа)1. Контекст: проверка боем на мобильном устройстве
Честно признаюсь: я не держу в памяти тонкости криптографических примитивов и байтовых буферов в Dart. Моя задача как архитектора — видеть всю картину целиком: как данные рождаются на десктопе, как упаковываются в зашифрованный конверт, как передаются на телефон и что происходит, когда администратор нажимает кнопку подключения на ходу, стоя где-нибудь в метро или на объекте.
Синхронизация между устройствами на десктопе выглядела эталонно: мастер-пароль, сквозное шифрование AES-GCM, сокеты, чистая передача. Но стоило накатить свежую сборку на реальный Android-смартфон и подтянуть базу хостов, как начались странности.
Серверы, прекрасно открывавшиеся на ноутбуке, на телефоне наотрез отказывались пускать внутрь: терминал закрывался до авторизации, выбрасывая сухой SSHAuthAbortError. Хуже того: при подключении к серверам с жесткими правилами безопасности мобильный клиент внезапно попадал под временный бан сетевых экранов. А открыв настройки приложения на пятидюймовом экране, пользователь натыкался на огромный блок настройки API-ключей Gemini и параметров ИИ — функционала, который создавался для десктопа и на мобильном устройстве был попросту не нужен.
Стало ясно: мобильному клиенту требуется основательная ревизия и очистка от всего лишнего.
2. Подводные камни: пустые массивы, агрессивный пинг и дубликаты отпечатков
Мы начали системный разбор каждого звена цепочки и нащупали сразу четыре скрытых архитектурных дефекта:
-
Криптографическая ловушка в
SyncManager:
Когда хост использует аутентификацию по паролю, полеencryptedPrivateKeyв базе данных имеет нулевую длину (Uint8List(0)). Во время синхронизации цикл экспорта пытался расшифровать приватный ключ через AES-GCM. Криптографический модуль честно возмущался: расшифровка нуля байт невозможна, выбрасывалсяArgumentError. Из-за общего блокаtry-catchисключение прерывало всю итерацию: обработка закрытого ключа падала, а следовавший за ней экспорт серверного пароля (encryptedPassphrase) молча пропускался! В итоге зашифрованный пароль улетал на телефон «как есть» — зашифрованным локальным мастер-паролем десктопа. Естественно, мобильное хранилище расшифровать его не могло и возвращалоpassword = null. -
Молчаливый отказ контроллера:
Когда мобильныйSessionConnectControllerполучалpassword == nullиз-за сбоя дешифрования, он не сообщал об ошибке пользователю, а наивно пытался установить SSH-сессию вовсе без учетных данных. Удаленный сервер мгновенно сбрасывал соединение, оставляя оператора в полном недоумении. -
Сетевая агрессия сокетного пинга:
Фоновый таймерPingMonitorNotifierкаждые 15 секунд открывал сырой TCP-сокет на 22-й порт каждого добавленного сервера и сбрасывал его для замера задержки. Если на сервере установлен Fail2ban или настроен лимит незавершенных SSH-сессий, такое поведение воспринимается как попытка сканирования портов. Мобильный клиент сам провоцировал собственную блокировку. -
Риск падения на дубликатах
known_hosts:
В методеfindKnownHostзапрос завершался вызовомgetSingleOrNull(). Если в процессе импорта или синхронизации в таблице появлялись две записи для одного хоста, вызов гарантированно падал с критической ошибкойStateError: Too many elements.
3. Логика решений с ИИ: системное исправление и фокус на эргономике
Мы сформулировали четкие технические требования и провели последовательную переработку:
- Изоляция криптографических потоков:
ВSyncManagerобработка приватных ключей и паролей была строго разделена. Дешифрование приватного ключа теперь выполняется только при явном условииk.encryptedPrivateKey.isNotEmpty. Пустые массивы больше не вызывают сбоев. Временный буфер пароляrawPassBytesочищается по правилам строгой гигиены памяти — черезfillRange(0, length, 0)в блокеfinally. - Безопасный поиск хост-ключей:
В репозиторииknown_host_repository.dartдобавлен оператор..limit(1), гарантирующий безопасную выборку без рискаStateError. - Честная диагностика авторизации:
ВSessionConnectControllerвнедрена проверка: если при наличии сохраненных учетных данных их расшифровка вернула пустоту, подключение немедленно прерывается с понятным диагнозом в UI, подсказывающим перепроверить пароль или повторить синхронизацию. - Бережное отношение к мобильной сети:
Агрессивный 15-секундный таймер опроса на Android полностью выключен. Приложение выполняет единственный мягкий замер задержки при старте, а для актуализации данных добавлен нативный жест Pull-to-refresh (RefreshIndicator) в каталоге хостов. - Очистка от десктопного балласта:
Из мобильного профиля настроек целиком убрана карточка конфигурации ИИ и Gemini. Мобильный клиент должен оставаться компактным, быстрым и отзывчивым инструментом администратора. - Сенсорная адаптивность:
- В каталоге хостов появилась удобная горизонтальная лента чипов-фильтров (
All,PROD,STAGE,DEVи папки). - Диалог добавления сервера разворачивается на всю ширину экрана с компактными полями для удобного ввода с сенсорной клавиатуры.
- Панель быстрого ввода терминала пополнилась частыми Unix-символами (
:,_,$,&,PgUp,PgDn,ENTER), а стрелки навигации сгруппированы рядом сCtrlиAlt. - В горизонтальном режиме терминала высота шапки сжата до 36dp, отдавая драгоценные пиксели под консольные строки.
- В каталоге хостов появилась удобная горизонтальная лента чипов-фильтров (
4. Итог
- Зарегистрирован и полностью закрыт инцидент
BUG-051. - Серверы после E2EE-синхронизации открываются на Android стабильно и предсказуемо.
- Мобильный интерфейс избавлен от неиспользуемых настроек и оптимизирован для сенсорного ввода одной рукой.
- Собрана и протестирована тестовая сборка
app-release.apk(68.8 МБ). - Анализатор кода сообщает об абсолютной чистоте (0 предупреждений), все тесты монорепозитория (63
storage_vault, 150terminal_ui, 61shellit) пройдены на 100%.