İçeriğe geç
Tüm yazılar

Room veritabanını 2026'da şifrelemek: SQLCipher, Keystore ve veri kaybetmeden geçiş

Bir Room/SQLite veritabanını Android'de dinlenme halindeyken SQLCipher ile şifrelemek için pratik bir rehber — Keystore üzerinden anahtar yönetimi, tek seferlik geçiş ve gerçek performans maliyeti.

MFKAPPS 5 dk okuma

Biyometrik bir istem, uygulamayı kimin açabileceğine karar verir. Uygulama kapalıyken veritabanı dosyasında ne durduğuna karar vermez. Kilidi açık, root’lanmış bir cihazdan app.db’yi çekin — ya da şifrelenmemiş bir yedekten — düz bir Room veritabanı herhangi bir SQLite görüntüleyicide, hiçbir istem gerekmeden açılır. Veri bir parmak izinin arkasında kilitlenmeye değerse, dinlenme halindeyken de şifrelenmeye değerdir. Bunlar iki farklı problemdir, ve çoğu rehber sadece birincisini çözer.

Bu, gerçekten gönderdiğim versiyon: Room’u saran SQLCipher, sabit kodlanmış bir dize yerine Android Keystore’da tutulan bir anahtar, ve mevcut kullanıcıları şifrelenmemiş bir veritabanından şifrelenmiş birine tek bir satır bile kaybetmeden taşıyan bir geçiş.

Bunun biyometrik doğrulamadan neden ayrı olduğu

Granyn’i BiometricPrompt ve Keystore destekli bir CryptoObject ile kilitlemek hakkında daha önceki bir yazıda yazmıştım. O desen belirli alanları şifreler, ve sadece işletim sistemi kullanıcıyı o tek işlem için “doğrulanmış” saydığı sürece. Ekrandaki bir bakiyeyi kapatmak için doğru araç budur.

Veritabanı dosyasının kendisi için hiçbir şey yapmaz. Room’un varsayılan SupportSQLiteOpenHelper’ı diske düz SQLite sayfaları yazar — dosyaya sahip olan biri için sqlite3 app.db ile, doğrulama olsun olmasın, anında okunabilir. Dinlenme halinde bütün veritabanı şifrelemesi farklı bir katmandır: herhangi bir ekranın kilitli olup olmamasından bağımsız olarak dosyayı korur. Genellikle ikisine de ihtiyacınız olur, ama farklı tehditleri çözerler, ve hiçbiri diğerinin yerini tutmaz.

SQLCipher’ı Room’a bağlamak

Android için SQLCipher, standart SupportSQLiteOpenHelper.Factory için doğrudan takılabilir bir yedek sunar, bu yüzden Room kurulumu neredeyse hiç değişmez:

// 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’un kendisi için entegrasyon yüzeyinin tamamı bu kadar — DAO’lar, entity’ler, Flow sorguları, geçişlerin hepsi tam olarak eskisi gibi kalır. SQLCipher’ın getirdiği tek yeni problem, sizin için çözmediği problemdir: passphrase nereden geliyor, ve şimdi şifrelenmiş bir dosyanın yanında duran düz metin bir anahtar olmayacak şekilde nasıl saklanıyor — ki bu güvenlik tiyatrosu olurdu.

Anahtarı sabit kodlanmış bir dize değil, Android Keystore ile oluşturmak

Parolanın, işletim sisteminin kendisinin koruduğu bir yerde durması gerekir, bir kaynak dosyasında ya da BuildConfig dizesinde değil. Keystore tam olarak bunun için yapılmıştır: donanım destekli depolamadan asla çıkmayan bir AES anahtarı oluşturun, onu rastgele bir parolayı şifrelemek için kullanın, ve sadece şifrelenmiş parolayı SharedPreferences ya da DataStore’da saklayın.

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
}

Biyometrik korumalı olandan farklı olarak, bu anahtarda setUserAuthenticationRequired(true) yok. Bu bilinçli bir tercih: veritabanının, her açılışta bir parmak izi istemine takılmadan, arka plan işleri de dahil, uygulama açılışında açılması gerekiyor. Bu anahtarın işi daha dar — parolayı diskten düz metin olarak uzak tutmak — her okumayı biyometri arkasında kilitlemek değil. Bir ekranın o daha güçlü garantiye ihtiyacı varsa, önceki CryptoObject desenini belirli alanların üzerine katman olarak ekleyin; bir anahtarın iki işi de yapmasını sağlamaya çalışmayın.

Tek seferlik geçiş: düz metinden şifreliye

Mevcut kullanıcıların diskte zaten düz metin bir app.db dosyası var. SQLCipher bir dosyayı “şifrelemeye başlayamaz” — dosyayı bir kez yeniden yazarsınız, kendi sqlcipher_export() mekanizmasını kullanarak; bu, düz metin bir kaynak veritabanındaki her tabloyu yeni oluşturulmuş şifreli bir veritabanına kopyalar:

fun migrateToEncrypted(context: Context, passphrase: ByteArray) {
    val plainDbFile = context.getDatabasePath("app.db")
    if (!plainDbFile.exists()) return // yeni kurulum, taşınacak bir şey yok

    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) { "Geçişten sonra satır sayısı uyuşmuyor" }

    plainDbFile.delete()
    File(encryptedPath).renameTo(plainDbFile)
}

Satır sayıları üzerindeki check() paranoya değildir — güvenebileceğiniz bir geçiş ile bir destek e-postasından başarısız olduğunu öğrendiğiniz bir geçiş arasındaki farktır. Bunu bir kez, DataStore’da bir sürüm bayrağının (db_encrypted_v1 = true) arkasında, Room.databaseBuilder() veritabanını hiç açmadan önce çalıştırın. Hata fırlatırsa, orijinal düz metin dosyayı silmeyin — uygulamayı eski yolda bırakın ve kaydedin; geri dönüşü olmayan yarı geçişli bir durum riski almaktansa.

Neye mal olur

SQLCipher bedava değil. Orta seviye bir cihazda, düz SQLite’a kıyasla okuma/yazma verimliliğinde kabaca %5-15 arası bir ek yük bekleyin, çoğunlukla sayfa başına AES-256 şifrelemesinden ve okuma sırasındaki ek HMAC doğrulamasından. Birkaç bin satırlık bir bütçe ya da abonelik takip uygulaması için bu fark edilmez — 8ms süren sorgular 9ms sürer. Tek bir işlemde on binlerce satırlık toplu içe aktarma yapan herhangi bir şey için, göndermeden önce ölçün; ek yükün fark edilecek kadar birikeceği yer orasıdır.

Diğer gerçek maliyet kurtarmadır. Sarmalanmış parolayı kaybedin — bir fabrika ayarlarına dönüşten Keystore sıfırlanması, uygulamaya özel anahtarları temizleyen bir işletim sistemi hatası — ve şifreli veritabanı tasarım gereği kurtarılamaz. Eklenecek gizli bir arka kapı yoktur; “şifreli” olmanın anlamı budur. Bunu şifrelenmemiş, kullanıcının başlattığı bir dışa aktarma (CSV, düz JSON) ile gerçek yedekleme hikayesi olarak eşleştirin, böylece “Keystore anahtarı kayboldu” kalıcı veri kaybı değil, bir rahatsızlık olur.

Gerçekte neyin şifrelenmesi gerekir

Her yerel öncelikli uygulamanın buna ihtiyacı yok. SQLCipher’a uzanmadan önce sorulması gereken soru: ham dosya sızarsa veri ne ortaya çıkarır? Bir hidrasyon uygulamasının zaman damgası kaydı düşük riskli. Bir bütçe uygulamasının işlem notları, ya da bir ilaç kaydının doz geçmişi, öyle değil — bunlar bir gizlilik politikasının söz verdiği türden şeyler, ve “dosya dinlenme halinde AES-256 ile şifrelendi” SQLCipher’ın sadece yazmanıza değil, gerçekten tutmanıza izin verdiği bir söz.