Шифрование базы данных Room в 2026: SQLCipher, Keystore и миграция без потери данных
Практическое руководство по шифрованию базы данных Room/SQLite в состоянии покоя на Android с помощью SQLCipher — управление ключами через Keystore, разовая миграция и реальная цена по производительности.
Биометрический запрос решает, кто может открыть приложение. Он не решает, что лежит в файле базы данных, пока приложение закрыто. Извлеките app.db с разблокированного, рутованного устройства — или из незашифрованной резервной копии — и обычная база данных Room открывается в любом просмотрщике SQLite, без единого запроса. Если данные заслуживают блокировки за отпечатком пальца, они заслуживают и шифрования в состоянии покоя. Это две разные проблемы, и большинство руководств решают только первую.
Вот версия, которую я реально выпустил в продакшн: SQLCipher, оборачивающий Room, ключ, хранящийся в Android Keystore вместо строки, зашитой в код, и миграция, которая переводит существующих пользователей с базы в открытом виде на зашифрованную, не теряя ни одной строки.
Почему это отдельная задача от биометрической аутентификации
В более ранней статье я писал о блокировке Granyn с помощью BiometricPrompt и CryptoObject, опирающегося на Keystore. Этот паттерн шифрует конкретные поля, и только пока ОС считает пользователя «аутентифицированным» для этой конкретной операции. Это правильный инструмент для блокировки баланса, показанного на экране.
Но он никак не защищает сам файл базы данных. Стандартный SupportSQLiteOpenHelper в Room пишет на диск обычные, незашифрованные страницы SQLite — их можно прочитать через sqlite3 app.db в тот момент, как файл оказался у кого-то в руках, независимо от аутентификации. Полное шифрование базы данных в состоянии покоя — это другой уровень: он защищает файл независимо от того, заблокирован ли конкретный экран. Обычно нужны оба механизма, но они закрывают разные угрозы, и ни один не заменяет другой.
Подключение SQLCipher к Room
SQLCipher для Android предоставляет прямую замену стандартной SupportSQLiteOpenHelper.Factory, так что настройка Room почти не меняется:
// build.gradle.kts
implementation("net.zetetic:android-database-sqlcipher:4.6.1")
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
fun buildEncryptedDatabase(context: Context, passphrase: ByteArray): AppDatabase {
val factory = SupportOpenHelperFactory(passphrase)
return Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.openHelperFactory(factory)
.build()
}
Это вся поверхность интеграции для самого Room — DAO, сущности, запросы через Flow, миграции остаются в точности такими же. Единственная новая проблема, которую приносит SQLCipher, — это та, которую он не решает за вас: откуда берётся passphrase и как его хранить так, чтобы это не был просто открытый ключ, лежащий рядом с теперь-зашифрованным файлом — что было бы имитацией безопасности, а не реальной безопасностью.
Ключ через Android Keystore, а не зашитую строку
Парольная фраза должна храниться там, где её защищает сама ОС, — не в файле ресурсов и не в строке BuildConfig. Keystore создан именно для этого: сгенерировать ключ AES, который никогда не покидает аппаратно защищённое хранилище, использовать его для шифрования случайной парольной фразы и хранить в SharedPreferences или DataStore только зашифрованную парольную фразу.
private const val KEY_ALIAS = "app_db_passphrase_key"
fun getOrCreateWrappingKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
val spec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
keyGenerator.init(spec)
return keyGenerator.generateKey()
}
fun getOrCreatePassphrase(prefs: SharedPreferences): ByteArray {
prefs.getString("wrapped_db_key", null)?.let { stored ->
return decryptStoredPassphrase(stored, prefs)
}
val passphrase = ByteArray(32).also { SecureRandom().nextBytes(it) }
storeWrappedPassphrase(passphrase, prefs)
return passphrase
}
Обратите внимание: в отличие от ключа, защищённого биометрией, здесь нет setUserAuthenticationRequired(true). Это осознанное решение: база данных должна открываться при запуске приложения, в том числе из фоновой задачи, не блокируясь каждый раз на запросе отпечатка. Задача этого ключа уже — не пускать парольную фразу на диск в открытом виде, а не блокировать каждое чтение биометрией. Если какому-то экрану нужна та более сильная гарантия, наложите более ранний паттерн с CryptoObject поверх конкретных полей; не пытайтесь заставить один ключ делать обе работы.
Разовая миграция: из открытого текста в зашифрованный
У существующих пользователей на диске уже лежит app.db в открытом виде. SQLCipher не может просто «начать» шифровать файл — вы переписываете файл один раз, используя его собственный механизм sqlcipher_export(), который копирует каждую таблицу из незашифрованной исходной базы данных во вновь созданную зашифрованную:
fun migrateToEncrypted(context: Context, passphrase: ByteArray) {
val plainDbFile = context.getDatabasePath("app.db")
if (!plainDbFile.exists()) return // новая установка, мигрировать нечего
val encryptedPath = context.getDatabasePath("app_encrypted.db").absolutePath
val plainDb = SQLiteDatabase.openDatabase(
plainDbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE
)
plainDb.rawExecSQL("ATTACH DATABASE '$encryptedPath' AS encrypted KEY '${passphrase.toHexKey()}'")
plainDb.rawExecSQL("SELECT sqlcipher_export('encrypted')")
plainDb.rawExecSQL("DETACH DATABASE encrypted")
plainDb.close()
val originalRowCount = countRows(plainDbFile.absolutePath)
val migratedRowCount = countRows(encryptedPath, passphrase)
check(originalRowCount == migratedRowCount) { "Количество строк не совпадает после экспорта" }
plainDbFile.delete()
File(encryptedPath).renameTo(plainDbFile)
}
check() по количеству строк — это не паранойя, а разница между миграцией, которой можно доверять, и той, о провале которой вы узнаёте из письма в поддержку. Запускайте это один-единственный раз, за флагом версии в DataStore (db_encrypted_v1 = true), до того как Room.databaseBuilder() вообще откроет базу данных. Если возникла ошибка — не удаляйте исходный незашифрованный файл: оставьте приложение на старом пути и залогируйте это, вместо того чтобы рисковать наполовину мигрированным состоянием без пути назад.
Во что это обходится
SQLCipher не бесплатен. На устройстве среднего уровня стоит ожидать примерно 5–15% накладных расходов на пропускную способность чтения/записи по сравнению с обычным SQLite — в основном из-за постраничного шифрования AES-256 и дополнительной проверки HMAC при чтении. Для приложения учёта бюджета или подписок с несколькими тысячами строк это незаметно — запросы, занимавшие 8 мс, занимают 9. Для всего, что делает массовый импорт десятков тысяч строк в одной транзакции, замерьте производительность перед релизом — именно там накладные расходы накапливаются настолько, что становятся заметны.
Другая реальная цена — восстановление. Потеряйте обёрнутую парольную фразу — сброс Keystore после восстановления заводских настроек, баг ОС, стирающий ключи, специфичные для приложения, — и зашифрованная база данных невосстановима по замыслу. Добавлять сюда чёрный ход нельзя; именно это и означает «зашифровано». Совместите это с незашифрованным экспортом по инициативе пользователя (CSV, обычный JSON) как реальной стратегией резервного копирования, чтобы «ключ Keystore пропал» было неудобством, а не безвозвратной потерей данных.
Что действительно стоит шифровать
Не каждому местно-ориентированному приложению это нужно. Вопрос, который стоит задать перед тем, как тянуться за SQLCipher: что раскрывают данные, если сырой файл утечёт? Журнал временных меток приложения для отслеживания воды — низкий риск. Заметки о транзакциях в бюджетном приложении или история приёма препаратов в журнале лекарств — совсем другое дело: это как раз то, что обещает политика конфиденциальности, и «файл был зашифрован AES-256 в состоянии покоя» — это обещание, которое SQLCipher позволяет не просто записать, а реально сдержать.
// По теме
Ещё из журнала
Разработка Granyn: трекер бюджета без входа в банк, всего на трёх таблицах
Как Granyn отслеживает расходы в разных валютах и распознаёт повторяющиеся счета, не подключая ни одного банковского аккаунта — схема Room под капотом и компромиссы, которые она навязывает.
Local-first на Android в 2026 году: SQLite, Room и хранение пользовательских данных на устройстве
Руководство 2026 года по созданию local-first приложений для Android на Room и SQLite — проектирование схемы, миграции, WAL, экспорт и когда (и когда не стоит) добавлять синхронизацию.
Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему
Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.