Aller au contenu
Tous les articles

Chiffrer une base Room en 2026 : SQLCipher, le Keystore, et migrer sans perdre de données

Un guide pratique pour chiffrer au repos une base Room/SQLite sur Android avec SQLCipher — gestion des clés via le Keystore, migration unique, et le vrai coût en performance.

MFKAPPS 6 min de lecture

Une invite biométrique décide qui a le droit d’ouvrir l’application. Elle ne décide pas de ce qui se trouve dans le fichier de base de données pendant que l’application est fermée. Récupérez app.db sur un appareil déverrouillé et rooté — ou dans une sauvegarde non chiffrée — et une base Room ordinaire s’ouvre dans n’importe quel visualiseur SQLite, sans invite requise. Si des données méritent d’être verrouillées derrière une empreinte digitale, elles méritent aussi d’être chiffrées au repos. Ce sont deux problèmes différents, et la plupart des guides ne résolvent que le premier.

Voici la version que j’ai réellement mise en production : SQLCipher qui enveloppe Room, une clé conservée dans l’Android Keystore plutôt qu’une chaîne codée en dur, et une migration qui fait passer les utilisateurs existants d’une base en clair à une base chiffrée sans perdre une seule ligne.

Pourquoi c’est séparé de l’authentification biométrique

J’ai écrit sur le verrouillage de Granyn avec BiometricPrompt et un CryptoObject adossé au Keystore dans un article précédent. Ce schéma chiffre des champs spécifiques, et seulement tant que l’OS considère l’utilisateur « authentifié » pour cette opération précise. C’est le bon outil pour verrouiller un solde affiché à l’écran.

Cela ne fait rien pour le fichier de base de données lui-même. Le SupportSQLiteOpenHelper par défaut de Room écrit des pages SQLite en clair sur le disque — lisibles avec sqlite3 app.db dès qu’on a le fichier, authentification ou non. Le chiffrement complet de la base au repos est une couche différente : elle protège le fichier, indépendamment du fait qu’un écran donné soit verrouillé ou non. En général on veut les deux, mais ils répondent à des menaces différentes, et aucun ne remplace l’autre.

Brancher SQLCipher sur Room

SQLCipher pour Android fournit un remplacement direct pour la SupportSQLiteOpenHelper.Factory standard, donc la configuration de Room change à peine :

// 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()
}

C’est toute la surface d’intégration pour Room lui-même — DAO, entités, requêtes Flow, migrations, tout reste exactement comme avant. Le seul nouveau problème que SQLCipher introduit est celui qu’il ne résout pas pour vous : d’où vient passphrase, et comment est-elle stockée pour que ce ne soit pas juste une clé en clair posée à côté d’un fichier désormais chiffré — ce qui serait du théâtre de sécurité.

La sécuriser avec l’Android Keystore, pas une chaîne codée en dur

La phrase secrète doit résider quelque part que l’OS lui-même protège, pas dans un fichier de ressources ni une chaîne BuildConfig. Le Keystore est fait exactement pour ça : générer une clé AES qui ne quitte jamais le stockage matériel sécurisé, l’utiliser pour chiffrer une phrase secrète aléatoire, et ne stocker que la phrase secrète chiffrée dans SharedPreferences ou 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
}

Notez qu’il n’y a pas de setUserAuthenticationRequired(true) sur cette clé, contrairement à celle protégée par biométrie. C’est délibéré : la base doit s’ouvrir au lancement de l’application, y compris depuis une tâche en arrière-plan, sans bloquer sur une invite d’empreinte à chaque fois. Le rôle de cette clé est plus étroit — garder la phrase secrète hors du disque en clair — pas verrouiller chaque lecture derrière la biométrie. Si un écran a besoin de cette garantie plus forte, superposez le schéma CryptoObject précédent sur des champs spécifiques ; n’essayez pas de faire faire les deux tâches à une seule clé.

La migration unique : du clair au chiffré

Les utilisateurs existants ont déjà un app.db en clair sur le disque. SQLCipher ne peut pas simplement « commencer » à chiffrer un fichier — vous réécrivez le fichier une fois, via son propre mécanisme sqlcipher_export(), qui copie chaque table d’une base source en clair vers une base chiffrée nouvellement créée :

fun migrateToEncrypted(context: Context, passphrase: ByteArray) {
    val plainDbFile = context.getDatabasePath("app.db")
    if (!plainDbFile.exists()) return // nouvelle installation, rien à migrer

    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) { "Écart du nombre de lignes après l'export" }

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

Le check() sur le nombre de lignes n’est pas de la paranoïa — c’est la différence entre une migration à laquelle vous pouvez faire confiance et une qui échoue sans que vous le sachiez, sinon par un e-mail de support. Exécutez ceci une seule fois, protégé par un indicateur de version dans DataStore (db_encrypted_v1 = true), avant que Room.databaseBuilder() n’ouvre jamais la base. En cas d’échec, ne supprimez pas le fichier en clair d’origine — laissez l’application sur l’ancien chemin et journalisez l’erreur, plutôt que de risquer un état à moitié migré sans filet.

Ce que ça coûte

SQLCipher n’est pas gratuit. Sur un appareil milieu de gamme, attendez-vous à un surcoût d’environ 5 à 15 % sur le débit lecture/écriture par rapport à du SQLite ordinaire, principalement dû au chiffrement AES-256 par page et à la vérification HMAC supplémentaire à la lecture. Pour une application de budget ou de suivi d’abonnements avec quelques milliers de lignes, c’est imperceptible — des requêtes qui prenaient 8 ms en prennent 9. Pour tout ce qui fait des imports en masse de dizaines de milliers de lignes dans une seule transaction, mesurez avant de publier ; c’est là que le surcoût s’accumule suffisamment pour être remarqué.

L’autre coût réel, c’est la récupération. Perdez la phrase secrète enveloppée — une réinitialisation du Keystore après une restauration d’usine, un bug OS qui efface les clés propres à l’application — et la base chiffrée est irrécupérable par conception. Il n’y a pas de porte dérobée à ajouter ; c’est ce que signifie « chiffré ». Associez cela à un export non chiffré, initié par l’utilisateur (CSV, JSON en clair) comme véritable stratégie de sauvegarde, pour que « la clé du Keystore a disparu » reste un désagrément, pas une perte de données définitive.

Ce qu’il faut réellement chiffrer

Toutes les applications local-first n’en ont pas besoin. La question à se poser avant de se tourner vers SQLCipher : que révèle la donnée si le fichier brut fuit ? Le journal d’horodatage d’une application d’hydratation est peu sensible. Les notes de transaction d’une application de budget, ou l’historique de prises d’une application de suivi médicamenteux, ne le sont pas — ce sont le genre de choses qu’une politique de confidentialité promet, et « le fichier était chiffré en AES-256 au repos » est une promesse que SQLCipher permet réellement de tenir, pas seulement d’écrire.