本文へスキップ
すべての記事

2026年、Roomデータベースを暗号化する: SQLCipher、Keystore、データを失わない移行

AndroidでRoom/SQLiteデータベースを保存時に暗号化するための実践ガイド — Keystoreによる鍵管理、一度きりの移行、そして実際のパフォーマンスコスト。

MFKAPPS 1 分で読めます

生体認証のプロンプトは、誰がアプリを開けるかを決める。アプリが閉じている間にデータベースファイルの中に何が入っているかは決めない。ロック解除されたroot化端末からapp.dbを取り出すか — あるいは暗号化されていないバックアップから取り出せば — 通常のRoomデータベースは、プロンプトなしでどんなSQLiteビューアでも開く。データが指紋の後ろにロックする価値があるなら、保存時にも暗号化する価値がある。これらは別の問題であり、ほとんどのガイドは前者しか解決していない。

これは実際に私が出荷したバージョンだ。Roomをラップする SQLCipher、ハードコードされた文字列の代わりに Android Keystore に保持される鍵、そして既存ユーザーを1行も失うことなく平文データベースから暗号化データベースへ移行する仕組み。

これが生体認証と別問題である理由

以前の記事で、GranynBiometricPrompt と Keystore 裏付けの CryptoObject でロックする方法について書いた。あのパターンは特定のフィールドを暗号化するが、それはOSがユーザーをその1回の操作について「認証済み」とみなしている間だけだ。画面上の残高をロックするには正しい道具だ。

だがデータベースファイル自体には何もしない。Roomのデフォルトの SupportSQLiteOpenHelper は平文のSQLiteページをディスクに書き込む — 認証の有無にかかわらず、ファイルを手にした瞬間に sqlite3 app.db で読める。保存時のデータベース全体の暗号化は別の層だ。特定の画面がロックされているかどうかとは無関係に、ファイル自体を守る。通常は両方必要だが、それぞれ異なる脅威に対処するものであり、どちらも他方の代わりにはならない。

SQLCipherをRoomに組み込む

Android版SQLCipher は標準の 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で鍵を作る

パスフレーズはOS自体が保護する場所に置く必要がある。リソースファイルでも BuildConfig 文字列でもない。Keystoreはまさにこのために作られている。ハードウェア裏付けのストレージから絶対に出ないAES鍵を生成し、それを使ってランダムなパスフレーズを暗号化し、暗号化されたパスフレーズだけを SharedPreferencesDataStore に保存する。

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 パターンを特定のフィールドの上に重ねればいい。1つの鍵に両方の仕事をさせようとしないことだ。

一度きりの移行: 平文から暗号化へ

既存ユーザーはすでにディスク上に平文の 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は無料ではない。ミドルレンジ端末では、通常のSQLiteと比べて読み書きスループットで概ね5〜15%のオーバーヘッドを見込んでおくべきで、主にページ単位のAES-256暗号化と読み取り時の追加HMAC検証によるものだ。数千行程度の家計簿や購読管理アプリでは、これは体感できない — 8msかかっていたクエリが9msになる程度だ。1つのトランザクションで数万行の一括インポートを行うようなものについては、出荷前に必ずベンチマークすること。オーバーヘッドが目に見えるほど積み重なるのはそこだ。

もう一つの本当のコストは復旧だ。ラップされたパスフレーズを失う — 工場出荷状態への復元によるKeystoreのリセット、アプリ固有の鍵を消してしまうOSのバグ — と、暗号化されたデータベースは設計上復旧不能になる。追加できる裏口はない。それが「暗号化されている」ことの意味だ。これを、ユーザー主導の暗号化されていないエクスポート(CSV、平文JSON)と組み合わせて実際のバックアップ手段とし、「Keystoreの鍵が消えた」ことが永久的なデータ損失ではなく、単なる不便で済むようにすべきだ。

実際に何を暗号化すべきか

すべてのローカルファーストアプリがこれを必要とするわけではない。SQLCipherに手を伸ばす前に問うべき問いは、生のファイルが漏洩したらデータは何を明らかにするか、だ。水分補給アプリのタイムスタンプログは低リスクだ。家計簿アプリの取引メモや、服薬管理アプリの服用履歴はそうではない — それらはプライバシーポリシーが約束する類のものであり、「ファイルは保存時にAES-256で暗号化されていた」は、SQLCipherが単に書くだけでなく実際に守ることを可能にする約束だ。