Перейти к содержимому
Все записи

Шифрование базы данных Room в 2026: SQLCipher, Keystore и миграция без потери данных

Практическое руководство по шифрованию базы данных Room/SQLite в состоянии покоя на Android с помощью SQLCipher — управление ключами через Keystore, разовая миграция и реальная цена по производительности.

MFKAPPS 5 мин чтения

Биометрический запрос решает, кто может открыть приложение. Он не решает, что лежит в файле базы данных, пока приложение закрыто. Извлеките 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 позволяет не просто записать, а реально сдержать.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

Разработка Granyn: трекер бюджета без входа в банк, всего на трёх таблицах

Как Granyn отслеживает расходы в разных валютах и распознаёт повторяющиеся счета, не подключая ни одного банковского аккаунта — схема Room под капотом и компромиссы, которые она навязывает.

#android #engineering #privacy
MFKAPPS 7 мин чтения

Local-first на Android в 2026 году: SQLite, Room и хранение пользовательских данных на устройстве

Руководство 2026 года по созданию local-first приложений для Android на Room и SQLite — проектирование схемы, миграции, WAL, экспорт и когда (и когда не стоит) добавлять синхронизацию.

#android #engineering #privacy
MFKAPPS 4 мин чтения

Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему

Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.

#android #engineering #room