Хранение чувствительных данных: Keychain и Android Keystore
Продвинутое4 минРедакция Innovialabs
Коротко
Токены и ключи храните в iOS Keychain и под защитой Android Keystore, а не в SharedPreferences, UserDefaults или AsyncStorage. Биометрию привязывайте к криптографическому ключу, а не к проверке «да/нет», и помните: секрет, вшитый в приложение, секретом не является.
Куда класть секреты
Короткий ответ:
- iOS — в Keychain. Это системное зашифрованное хранилище с настройками доступа.
- Android — данные шифруются ключом из Android Keystore. Сам ключ не покидает защищённую среду (TEE или отдельный чип StrongBox, если он есть на устройстве), а зашифрованные данные лежат в файле или базе.
- Кроссплатформенно — через обёртки:
flutter_secure_storage,react-native-keychain,expo-secure-store. Внутри они используют те же Keychain и Keystore.
const storage = FlutterSecureStorage();
await storage.write(key: "refresh_token", value: token);
final saved = await storage.read(key: "refresh_token");
Почему SharedPreferences и AsyncStorage не подходят
SharedPreferences, UserDefaults и AsyncStorage — это обычные файлы в папке приложения, без шифрования. От других приложений их защищает песочница ОС, но этого мало:
- на устройстве с root или jailbreak файлы читаются напрямую;
- данные могут попасть в резервные копии устройства, если их явно не исключить;
- при отладке и в незащищённых сборках содержимое легко извлечь;
- вредоносный код внутри вашего процесса (например, скомпрометированная библиотека) видит всё.
Эти хранилища подходят для настроек: тема, язык, флаг «онбординг пройден». Для токенов и ключей — нет.
Что именно хранить и как
| Данные | Рекомендация |
|---|---|
| Access token | Короткий срок жизни; в памяти или в защищённом хранилище |
| Refresh token | Keychain / Keystore, с удалением при выходе |
| Ключ шифрования локальной базы | Генерировать на устройстве, хранить в Keychain / Keystore |
| Пароль пользователя | Не хранить вообще, обменять на токен |
| Секрет серверного API | Не класть в приложение — держать на бэкенде |
Последняя строка важна: всё, что вшито в бинарник, можно извлечь декомпиляцией. Ключи сторонних сервисов, которые дают доступ к платным или приватным действиям, должны жить на сервере, а приложение должно ходить через ваш бэкенд.
Настройки доступа в Keychain
У элемента Keychain есть атрибут доступности:
WhenUnlocked— доступен, только когда устройство разблокировано;AfterFirstUnlock— доступен после первой разблокировки, нужен для фоновых задач;- варианты с
ThisDeviceOnlyне переносятся в резервные копии на другое устройство.
Для токенов обычно выбирают самый строгий вариант, который не ломает фоновую работу. Учтите, что на iOS элементы Keychain могут сохраниться после удаления приложения — при первом запуске стоит проверять это и очищать старые данные.
Биометрия: правильно и неправильно
Неправильно: вызвать Face ID или отпечаток, получить true и после этого прочитать токен из обычного хранилища. Такую проверку можно обойти, подменив результат на взломанном устройстве.
Правильно: привязать к биометрии сам ключ.
- iOS — элемент Keychain с access control (
biometryCurrentSet): система отдаст данные только после успешной биометрии. - Android — ключ в Keystore с
setUserAuthenticationRequired(true)иBiometricPromptсCryptoObject: расшифровка возможна только после подтверждения.
Вариант biometryCurrentSet делает ключ недействительным при добавлении нового отпечатка или лица — это защищает от ситуации, когда кто-то добавил свою биометрию.
Основы certificate pinning
Pinning — это проверка, что сервер предъявил конкретный ключ или сертификат, а не просто любой сертификат, которому доверяет устройство. Он защищает от перехвата трафика через подставной корневой сертификат.
Правила, без которых pinning опасен:
- закрепляйте хеш публичного ключа, а не весь сертификат;
- всегда держите резервный пин — ключ, который вы будете использовать после ротации;
- спланируйте обновление: если сертификат сменился, а приложение знает только старый пин, оно перестанет работать у всех пользователей.
На Android pinning задаётся в Network Security Config:
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">PRIMARY_KEY_HASH_BASE64=</pin>
<pin digest="SHA-256">BACKUP_KEY_HASH_BASE64=</pin>
</pin-set>
</domain-config>
</network-security-config>
На iOS — через ключ NSPinnedDomains в Info.plist или проверку в делегате URLSession.
Частые ошибки
- Токены в
AsyncStorage«временно, потом перенесём». - Секреты в
.env, который попадает в сборку. - Логи с токенами в консоли или системах аналитики.
- Нет очистки хранилища при выходе из аккаунта.
- Pinning без резервного ключа.