Чеклист защищённого хранения в Kotlin: токены, ключи и PII
Чеклист безопасного хранения в Kotlin: как выбрать между Android Keystore, EncryptedSharedPreferences и шифрованием базы для токенов, ключей и персональных данных.

Что вы пытаетесь защитить (простыми словами)
Безопасное хранение в бизнес‑приложении означает одно: если кто‑то получит телефон (или файлы вашего приложения), он не должен иметь возможность прочитать или повторно использовать сохранённое. Речь о данных в покое (на диске), а также о секретах, которые могут утечь через бэкапы, логи, отчёты о падениях или инструменты отладки.
Простой мысленный тест: что сможет сделать посторонний, если откроет папку хранения вашего приложения? Во многих приложениях самые ценные вещи — не фотографии и не настройки. Это небольшие строки, которые открывают доступ.
Локальное хранилище обычно содержит сессионные токены (чтобы пользователи оставались в системе), refresh‑токены, API‑ключи, ключи шифрования, личные данные (PII) вроде имён и почт, а также кэшированные бизнес‑записи для офлайн‑работы (заказы, тикеты, заметки клиентов).
Вот распространённые реальные сценарии провала:
- Потерянное или украденное устройство исследуют, токены копируют и используют для выдачи себя за пользователя.
- Малварь или «помощник»‑приложение читают локальные файлы на рутованном устройстве или через трюки с доступностью.
- Автоматические резервные копии переносят данные приложения туда, куда вы не планировали.
- Отладочные сборки логируют токены, пишут их в отчёты о падениях или отключают проверки безопасности.
Поэтому «просто положить в SharedPreferences» неприемлемо для всего, что даёт доступ (токены) или может навредить пользователям и компании (PII). Plain SharedPreferences — как записать секрет на стикер внутри приложения: удобно, но легко прочитать, если кто‑то получит доступ.
Самый полезный старт — назвать каждый хранимый элемент и задать два вопроса: разблокирует ли он что‑то и будет ли проблема, если он станет публичным? Остальное (Keystore, зашифрованные preferences, шифрование базы) вытекает из этого.
Классифицируйте данные: токены, ключи и PII
Безопасное хранилище становится проще, если перестать считать все «чувствительные данные» одинаковыми. Начните с перечисления того, что приложение сохраняет, и что произойдёт при утечке.
Токены — это не то же самое, что пароли. Access‑ и refresh‑токены предназначены для хранения, чтобы пользователь оставался в системе, но они всё ещё высокоценные секреты. Пароли не должны храниться вообще. Если нужен логин, храните только то, что действительно необходимо для сессии (обычно токены) и полагайтесь на сервер для проверки паролей.
Ключи — другая категория. API‑ключи, ключи подписи и ключи шифрования могут открывать целые системы, а не только аккаунт одного пользователя. Если кто‑то извлечёт их с устройства, он сможет автоматизировать злоупотребления в масштабе. Хорошее правило: если значение можно использовать вне приложения, чтобы выдать себя за приложение или расшифровать данные, относите его к более высокому риску, чем пользовательский токен.
PII — это всё, что может идентифицировать человека: почта, телефон, адрес, заметки клиентов, удостоверения, медицинские или финансовые данные. Даже безобидные поля становятся чувствительными в комбинации.
Быстрая система маркировки, которая хорошо работает на практике:
- Сессионные секреты: access token, refresh token, сессионная cookie
- Секреты приложения: API‑ключи, ключи подписи, ключи шифрования (по возможности избегайте размещать их на устройствах)
- Данные пользователя (PII): профиль, идентификаторы, документы, медицинская/финансовая информация
- Идентификаторы устройства и аналитики: advertising ID, device ID, install ID (во многих политиках тоже чувствительны)
Android Keystore: когда его использовать
Android Keystore лучше всего подходит, когда нужно защитить секреты, которые никогда не должны покидать устройство в открытом виде. Это сейф для криптографических ключей, а не база данных для ваших данных.
Для чего он хорош: генерация и хранение ключей для шифрования, расшифровки, подписи или проверки. Обычно вы шифруете токен или офлайн‑данные в другом месте, а ключ из Keystore — то, что их открывает.
Аппаратная защита: что это реально даёт
На многих устройствах ключи Keystore могут быть аппаратно защищены. Это значит, что операции с ключом выполняются в защищённой среде, и материал ключа нельзя извлечь. Это снижает риск от малвари, которая может читать файлы приложения.
Аппаратная защита не гарантирована на всех устройствах, поведение зависит от модели и версии Android. Проектируйте так, будто операции с ключом могут завершиться ошибкой.
Привязка к аутентификации пользователя
Keystore может требовать присутствия пользователя перед использованием ключа. Так вы привязываете доступ к биометрии или учётным данным устройства. Например, можно зашифровать экспортный токен и расшифровывать его только после подтверждения отпечатком или PIN.
Keystore подходит, когда нужен не‑экспортируемый ключ, когда требуется биометрическое или учётное подтверждение для чувствительных действий, и когда нужны per‑device секреты, которые не должны синхронизироваться или переноситься в бэкапе.
Планируйте подводные камни: ключи могут инвалидироваться после смены экрана блокировки, изменения биометрии или событий безопасности. Ожидайте отказов и реализуйте чистую запасную стратегию: обнаруживайте недействительные ключи, стирайте зашифрованные объекты и просите пользователя заново войти.
EncryptedSharedPreferences: когда этого достаточно
EncryptedSharedPreferences — хороший вариант по умолчанию для небольшого набора секретов в формате ключ‑значение. Это "SharedPreferences, но зашифрованный", поэтому кто‑то не сможет просто открыть файл и прочесть значения.
Под капотом используется мастер‑ключ для шифрования и расшифровки значений. Этот мастер‑ключ защищён Android Keystore, так что приложение не хранит сырой ключ шифрования в открытом виде.
Обычно этого достаточно для нескольких небольших элементов, которые часто читаются: access и refresh‑токены, идентификаторы сессии, идентификаторы устройства, флаги окружения или мелкие состояния вроде времени последней синхронизации. Также годится для крошечных фрагментов пользовательских данных, если уж очень нужно, но не превращайте его в свалку для PII.
Он не подходит для больших или структурированных данных. Если нужны офлайн‑списки, поиск или фильтрация по полям (клиенты, тикеты, заказы), EncryptedSharedPreferences становится медленным и неудобным. В этом случае нужна зашифрованная база данных.
Простое правило: если вы можете показать все ключи на одном экране, EncryptedSharedPreferences вероятно подойдёт. Если нужны строки и запросы — переходите дальше.
Шифрование базы данных: когда оно необходимо
Шифрование базы важно, когда вы храните больше, чем пару настроек или один токен. Если приложение держит бизнес‑данные на устройстве, считайте, что их можно извлечь с потерянного телефона, если вы их не защитите.
База данных имеет смысл, когда требуется офлайн‑доступ к записям, локальный кэш для производительности, история/аудит или длинные заметки и вложения.
Два распространённых подхода к шифрованию
Полное шифрование базы (часто в стиле SQLCipher) шифрует весь файл на диске. Приложение открывает его с ключом. Это просто: не нужно помнить, какие столбцы защищены.
Шифрование на уровне приложения (field encryption) шифрует только определённые поля перед записью, потом расшифровывает их при чтении. Это работает, если большинство записей не чувствительны или вы хотите сохранить существующий формат базы без изменений.
Компромисс: конфиденциальность против поиска и сортировки
Полное шифрование скрывает всё на диске, но после разблокировки приложение может выполнять обычные запросы.
Полевая шифровка защищает конкретные столбцы, но вы теряете простый поиск и сортировку по зашифрованным значениям. Сортировка по зашифрованной фамилии не будет работать корректно, а поиск становится либо «поиск после расшифровки» (медленно), либо «хранение дополнительных индексов» (сложнее и потенциально рискованно).
Основы управления ключами
Ключ базы не должен быть захардкоден или поставляться с приложением. Обычная схема: сгенерировать случайный ключ базы данных (DEK), затем хранить его в обёрнутом виде, зашифровав ключом из Android Keystore. При логауте можно удалить обёрнутый ключ и считать локальную базу одноразовой, либо сохранить, если приложение должно работать офлайн между сессиями.
Как выбрать: практическое сравнение
Вы не выбираете "самый безопасный" вариант во всех смыслах. Вы выбираете наиболее безопасный вариант, который подходит тому, как приложение использует данные.
Вопросы, которые действительно помогают сделать правильный выбор:
- Как часто читаются данные (каждый запуск или редко)?
- Сколько данных (несколько байт или тысячи записей)?
- Что произойдёт при утечке (неудобство, убытки, обязательный отчёт)?
- Нужен ли офлайн‑доступ, поиск или сортировка?
- Есть ли требования по соответствию (хранение, аудит, правила шифрования)?
Рабочее соответствие:
- Токены (OAuth access и refresh) обычно подходят для
EncryptedSharedPreferences, потому что они небольшие и часто читаются. - Ключевой материал должен храниться в Android Keystore, когда возможно, чтобы снизить шанс его копирования с устройства.
- PII и офлайн‑бизнес‑данные обычно требуют шифрования базы данных, когда вы храните больше пары полей или нужны офлайн‑списки и фильтрация.
Смешанные данные — нормальная ситуация в бизнес‑приложениях. Практичная схема: сгенерировать случайный DEK для локальной базы или файлов, хранить только обёрнутый DEK с помощью Keystore‑ключа и при необходимости вращать его.
Если сомневаетесь, выберите более простой безопасный путь: храните меньше. Избегайте офлайн‑PII, если это не строго необходимо, и держите ключи в Keystore.
Пошагово: реализуем безопасное хранение в Kotlin‑приложении
Сначала выпишите каждое значение, которое планируете хранить на устройстве, и точную причину его необходимости. Это самый быстрый способ предотвратить «на всякий случай» хранение.
Прежде чем писать код, решите правила: как долго жить каждому элементу, когда его менять и что означает «логаут». Access token может жить 15 минут, refresh token дольше, а офлайн‑PII может иметь твёрдое правило «удалить через 30 дней».
Реализация, которая остаётся поддерживаемой:
- Сделайте единый обёрточный слой "SecureStorage", чтобы остальная часть приложения никогда не работала напрямую с SharedPreferences, Keystore или базой.
- Помещайте каждый элемент в подходящее место: токены — в
EncryptedSharedPreferences, ключи шифрования защищайте Android Keystore, большие офлайн‑наборы — в зашифрованной базе. - Обрабатывайте ошибки намеренно. Если безопасное хранилище не работает, «fail closed»: не откатывайтесь молча к plain‑хранилищу.
- Добавьте диагностику без утечки данных: логируйте типы событий и коды ошибок, но никогда не токены, ключи или пользовательские данные.
- Пропишите пути удаления: логаут, удаление аккаунта и «очистить данные приложения» должны идти через один и тот же рул‑аут для очистки.
Потом протестируйте скучные случаи, которые ломают безопасное хранение в продакшне: восстановление из бэкапа, обновление с более старой версии приложения, смена настроек блокировки устройства, миграция на новый телефон. Убедитесь, что пользователи не попадают в цикл, где данные не расшифровываются, а приложение постоянно пытается.
Наконец, запишите решения на одной странице, чтобы команда могла следовать: что хранится, где, сроки хранения и что делать при ошибке расшифровки.
Частые ошибки, которые ломают безопасное хранение
Большинство провалов — не в выборе библиотеки. Они происходят, когда один маленький упрощённый шаг тихо копирует секреты туда, где вы этого не хотели.
Самый большой красный флаг — refresh‑токен (или долгоживущий сессионный токен), сохранённый в открытом виде где‑либо: в SharedPreferences, файле, «временном» кэше или в колонке локальной базы. Если кто‑то получит бэкап, дамп с рутованного устройства или артефакт отладки, этот токен может пережить пароль.
Секреты также утекут через видимость, а не через хранение. Логи полных заголовков, печать токенов при отладке или добавление «полезного» контекста в отчёты о падениях и аналитику может раскрыть креденшелы вне устройства. Рассматривайте логи как публичные.
Обращение с ключами — ещё одна распространённая проблема. Использование одного ключа для всего увеличивает радиус поражения. Отсутствие ротации ключей значит, что старые компрометации остаются валидными. Имейте план версионирования ключей, ротации и что происходит со старым зашифрованным контентом.
Не забывайте про «пути вне хранилища»
Шифрование не останавливает облачные бэкапы от копирования локальных данных. Не предотвращает скриншоты или запись экрана, захватывающие PII. Не спасает от отладочных сборок с ослабленными настройками или функций экспорта (CSV/share), которые могут пролить чувствительные поля. Буфер обмена тоже может выдать одноразовые коды или номера счетов.
Кроме того, шифрование не решает проблемы авторизации. Если приложение показывает PII после логаута или держит кэш, доступный без повторной аутентификации, это баг контроля доступа. Блокируйте UI, стирайте чувствительные кэши при логауте и проверяйте разрешения перед показом защищённых данных.
Операционные детали: жизненный цикл, логаут и крайние случаи
Безопасное хранилище — это не только где вы храните секреты. Это то, как они ведут себя со временем: когда приложение спит, когда пользователь выходит и когда устройство блокируется.
Для токенов продумайте полный жизненный цикл. Access‑токены должны быть кратковременными. Refresh‑токены следует считать паролями. Если токен истёк — обновляйте его тихо. Если обновление провалилось (ревокация, смена пароля, удаление устройства), прекратите попытки и потребуйте чистую повторную авторизацию. Поддерживайте ревокацию на стороне сервера: локальное хранение не поможет, если украденные креденшелы не аннулируются сервером.
Используйте биометрию для повторной аутентификации, но не для всего подряд. Показывайте запрос только там, где реально рискованное действие (просмотр PII, экспорт данных, смена платёжных реквизитов, показ одноразового ключа). Не запрашивайте при каждом открытии приложения.
При логауте будьте строгими и предсказуемыми:
- Сначала очищайте копии в памяти (кэши токенов в синглтонах, интерсепторах или ViewModels).
- Стирайте сохранённые токены и состояние сессии (включая refresh‑токены).
- Удаляйте или инвалидируйте локальные ключи шифрования, если архитектура это поддерживает.
- Удаляйте офлайн‑PII и кэшированные ответы API.
- Отключайте фоновые задачи, которые могут снова подтянуть данные.
Крайние случаи важны в бизнес‑приложениях: несколько аккаунтов на устройстве, рабочие профили, бэкап/восстановление, перенос между устройствами и частичный логаут (смена компании/рабочего пространства вместо полного выхода). Тестируйте принудительную остановку, обновления ОС и смену системного времени — дрейф времени может ломать логику истечения токенов.
Детекция подмены — компромисс. Базовые проверки (debuggable сборки, эмулятор, простые признаки root, verdicts Play Integrity) могут снизить уровень случайного злоупотребления, но решительный атакующий обойдёт их. Рассматривайте сигналы саботажа как вход риска: ограничивайте офлайн‑доступ, требуйте повторной аутентификации и логируйте событие.
Быстрый чеклист перед релизом
Используйте это перед выпуском. Он нацелен на места, где безопасное хранение чаще всего проваливается в реальных бизнес‑приложениях.
- Предположите, что устройство враждебно. Если у атакующего есть рут или полный образ устройства, сможет ли он прочитать токены, ключи или PII из файлов приложения, настроек, логов или скриншотов? Если ответ «возможно», переносите секреты на Keystore‑защищённую схему и держите полезную нагрузку зашифрованной.
- Проверьте бэкапы и переносы устройств. Держите чувствительные файлы вне Android Auto Backup и облачных бэкапов. Если потеря ключа при восстановлении ломает расшифровку, продумайте поток восстановления (переаутентификация и повторная загрузка вместо попытки расшифровать).
- Ищите случайный plain‑текст на диске. Проверьте временные файлы, HTTP‑кеши, отчёты о падениях, аналитические события и кэши изображений на предмет PII или токенов. Проверьте отладочные логи и дампы JSON.
- Истекайте и вращайте. Access‑токены должны быть кратковременными, refresh‑токены защищены, сессии на сервере — аннулируемы. Опишите ротацию ключей и поведение приложения при отказе токена (очистка, повторная аутентификация, одна попытка повторного запроса).
- Поведение при переустановке и смене устройства. Тестируйте удаление и повторную установку, затем открытие в офлайне. Если Keystore‑ключи пропали, приложение должно безопасно завершать работу (стирать зашифрованные данные, показывать вход, избегать частичных чтений, портящих состояние).
Быстрая проверка — «плохой день»: пользователь выходит, меняет пароль, восстанавливает бэкап на новом телефоне и открывает приложение в полёте. Результат должен быть предсказуем: либо данные расшифровываются для правильного пользователя, либо стираются и перекачиваются после входа.
Пример сценария: бизнес‑приложение с офлайн‑PII
Представьте полевой сервис, который работает в зонах плохой связи. Представители заходят в приложение утром, просматривают назначенных клиентов офлайн, добавляют заметки о встречах и синхронизируют позже. Здесь чеклист хранения перестаёт быть теорией и реально предотвращает утечки.
Практическое разделение:
- Access‑токен: короткий срок жизни, хранится в
EncryptedSharedPreferences. - Refresh‑токен: защищён сильнее и доступ к нему можно ограничивать Android Keystore.
- PII клиентов (имена, телефоны, адреса): храните в зашифрованной локальной базе.
- Офлайн‑заметки и вложения: в зашифрованной базе, с дополнительной осторожностью для экспорта и шаринга.
Добавьте две фичи — риск меняется.
Если вы включаете «запомнить меня», refresh‑токен становится главным входом обратно в аккаунт. Относитесь к нему как к паролю. В зависимости от пользователей вы можете требовать разблокировки устройства (PIN/паттерн/биометрия) перед его расшифровкой.
Если вы включаете офлайн‑режим, вы уже защищаете не только сессию, но и полный список клиентов, который сам по себе ценен. Это обычно переводит вас к шифрованию базы плюс строгим правилам логаута: стирать локальный PII, хранить только то, что нужно для следующего входа, и отменять фоновую синхронизацию.
Тестируйте на реальных устройствах, не только на эмуляторах. Минимум: проверьте поведение при блокировке/разблокировке, при переустановке, при бэкапе/восстановлении и разделение рабочих профилей.
Следующие шаги: сделайте это повторяемой привычкой команды
Безопасное хранение работает только тогда, когда это привычка. Напишите краткую политику хранения для команды: что где хранится (Keystore, EncryptedSharedPreferences, зашифрованная база), что никогда не хранится и что стирается при логауте.
Включите это в повседневную доставку: definition of done, code review и релиз‑чеки.
Лёгкий чек‑лист для ревьюера:
- Каждый сохраняемый элемент промаркирован (токен, ключевой материал или PII).
- Выбор хранилища обоснован в комментариях к коду.
- Логаут и переключение аккаунтов удаляют нужные данные (и только их).
- Ошибки и логи никогда не печатают секреты или полные PII.
- Кто‑то отвечает за политику и поддерживает её в актуальном состоянии.
Если ваша команда использует AppMaster (appmaster.io) для создания бизнес‑приложений и экспортирует Kotlin‑исходники для Android‑клиента, применяйте тот же подход с обёрткой SecureStorage, чтобы генерируемый и кастомный код следовали единой политике.
Начните с маленького proof‑of‑concept
Постройте небольшой POC, который сохраняет один auth‑токен и одну PII‑запись (например, телефон клиента, нужный офлайн). Потом протестируйте чистую установку, апгрейд, логаут, изменения на экране блокировки и очистку данных приложения. Расширяйте только после того, как поведение очистки корректно и повторяемо.
Вопросы и ответы
Начните с точного списка того, что вы сохраняете и зачем. Поместите короткие сессионные секреты, такие как access и refresh‑токены, в EncryptedSharedPreferences, держите криптографические ключи в Android Keystore, а зашифрованную базу данных используйте для офлайн‑бизнес‑записей и персональных данных (PII), когда полей больше пары или нужны запросы.
Plain SharedPreferences хранит значения в файле, который часто можно прочитать из бэкапов, с рутованного устройства или из артефактов отладки. Если значение — токен или какие‑либо PII, хранить его в обычных настройках — значит значительно облегчить его копирование и повторное использование вне приложения.
Используйте Android Keystore для генерации и хранения криптографических ключей, которые не должны быть извлекаемыми. Обычно эти ключи применяются для шифрования других данных (токенов, ключей базы данных, файлов) и при необходимости могут требовать подтверждения пользователя (биометрия или учётные данные устройства) перед использованием.
Это значит, что операции с ключом выполняются в защищённом аппаратном модуле, и материал ключа труднее извлечь, даже если злоумышленник может читать файлы приложения. Не полагайтесь на доступность аппаратной защиты на всех устройствах и готовьтесь к ошибкам: продумывайте сценарии восстановления, когда ключи недоступны или аннулированы.
Обычно достаточно для небольшого набора часто читаемых пар «ключ‑значение», таких как access/refresh‑токены, идентификаторы сессий и небольшие флаги состояния. Не годится для больших структурированных данных или офлайн‑записей, которые требуют поиска и фильтрации.
Выбирайте зашифрованную базу данных, когда храните офлайн‑бизнес‑данные или PII в масштабе, когда нужны запросы/поиск/сортировка или история офлайн‑изменений. Это уменьшает риск, что при утере устройства окажется доступен целый список клиентов или заметки.
Полное шифрование базы защищает весь файл в покое и проще в рассуждении: не нужно помнить, какие столбцы защищены. Полевая шифровка подходит для нескольких колонок, но усложняет поиск и сортировку, и легко случайно просочить данные через индексы или вычисляемые поля.
Сгенерируйте случайный ключ базы данных, затем храните его только в обёрнутом виде (wrapped), зашифрованным с помощью ключа, который хранится в Keystore. Никогда не хардкодьте ключи и не поставляйте их в приложении, и заранее решите, что делать при логауте или аннулировании ключа (часто: удалить wrapped‑ключ и считать локальные данные одноразовыми).
Ключи могут быть аннулированы при смене экрана блокировки, изменении биометрии, событиях безопасности или при миграции/восстановлении. Обрабатывайте это явно: при ошибках дешифрования безопасно очищайте зашифрованные объекты или базу и просите пользователя войти заново вместо бесконечных повторов или отката к_plaintext.
Большинство утечек происходит «вне хранилища»: логи, отчёты о падениях, аналитика, отладочные print, HTTP‑кеши, скриншоты, буфер обмена и пути резервного копирования. Относитесь к логам как к публичным: никогда не записывайте токены или полные PII, отключите случайный экспорт данных и убедитесь, что логаут очищает и оперативную память, и постоянное хранилище.


