Cifrar una base de datos Room en 2026: SQLCipher, el Keystore y migrar sin perder datos
Una guía práctica para cifrar en reposo una base de datos Room/SQLite en Android con SQLCipher — gestión de claves mediante el Keystore, la migración única y el coste real en rendimiento.
Un aviso biométrico decide quién puede abrir la aplicación. No decide qué hay en el archivo de base de datos mientras la aplicación está cerrada. Saca app.db de un dispositivo desbloqueado y rooteado — o de una copia de seguridad sin cifrar — y una base de datos Room normal se abre en cualquier visor de SQLite, sin pedir ningún aviso. Si los datos merecen bloquearse tras una huella dactilar, también merecen cifrarse en reposo. Son dos problemas distintos, y la mayoría de las guías solo resuelven el primero.
Esta es la versión que realmente publiqué: SQLCipher envolviendo Room, una clave guardada en el Android Keystore en lugar de una cadena escrita a fuego en el código, y una migración que mueve a los usuarios existentes de una base de datos en texto plano a una cifrada sin perder ni una fila.
Por qué esto es distinto de la autenticación biométrica
Escribí sobre bloquear Granyn con BiometricPrompt y un CryptoObject respaldado por el Keystore en un artículo anterior. Ese patrón cifra campos específicos, y solo mientras el sistema operativo considera al usuario “autenticado” para esa operación concreta. Es la herramienta correcta para bloquear un saldo que se muestra en pantalla.
No hace nada por el propio archivo de base de datos. El SupportSQLiteOpenHelper predeterminado de Room escribe páginas SQLite en texto plano en el disco — legibles con sqlite3 app.db en cuanto alguien tiene el archivo, con o sin autenticación. El cifrado de toda la base de datos en reposo es una capa distinta: protege el archivo, con independencia de si una pantalla concreta está bloqueada o no. Normalmente conviene tener ambas cosas, pero resuelven amenazas diferentes, y ninguna sustituye a la otra.
Conectar SQLCipher con Room
SQLCipher para Android ofrece un reemplazo directo para la SupportSQLiteOpenHelper.Factory estándar, así que la configuración de Room apenas cambia:
// 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()
}
Esa es toda la superficie de integración para Room en sí — DAOs, entidades, consultas con Flow, migraciones, todo se queda exactamente igual. El único problema nuevo que introduce SQLCipher es el que no resuelve por ti: de dónde sale passphrase, y cómo se guarda para que no sea simplemente una clave en texto plano al lado de un archivo ahora cifrado — lo cual sería teatro de seguridad.
Generar la clave con el Android Keystore, no con una cadena fija
La contraseña tiene que vivir en algún lugar que el propio sistema operativo proteja, no en un archivo de recursos ni en una cadena de BuildConfig. El Keystore está hecho exactamente para esto: generar una clave AES que nunca sale del almacenamiento respaldado por hardware, usarla para cifrar una contraseña aleatoria, y guardar solo la contraseña cifrada en SharedPreferences o 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
}
Fíjate en que no hay setUserAuthenticationRequired(true) en esta clave, a diferencia de la protegida por biometría. Es deliberado: la base de datos necesita abrirse al iniciar la app, incluso desde una tarea en segundo plano, sin bloquearse esperando un aviso de huella cada vez. El trabajo de esta clave es más limitado — mantener la contraseña fuera del disco en texto plano — no bloquear cada lectura tras la biometría. Si una pantalla necesita esa garantía más fuerte, añade el patrón de CryptoObject anterior sobre campos específicos; no intentes que una sola clave haga los dos trabajos.
La migración única: de texto plano a cifrado
Los usuarios existentes ya tienen un app.db en texto plano en el disco. SQLCipher no puede simplemente “empezar” a cifrar un archivo — reescribes el archivo una vez, usando su propio mecanismo sqlcipher_export(), que copia cada tabla de una base de datos origen en texto plano a una base de datos cifrada recién creada:
fun migrateToEncrypted(context: Context, passphrase: ByteArray) {
val plainDbFile = context.getDatabasePath("app.db")
if (!plainDbFile.exists()) return // instalación nueva, nada que migrar
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) { "El número de filas no coincide tras la exportación" }
plainDbFile.delete()
File(encryptedPath).renameTo(plainDbFile)
}
El check() sobre el número de filas no es paranoia — es la diferencia entre una migración en la que puedes confiar y una que descubres que falló por un correo de soporte. Ejecuta esto una sola vez, protegido tras una marca de versión en DataStore (db_encrypted_v1 = true), antes de que Room.databaseBuilder() abra la base de datos. Si falla, no borres el archivo original en texto plano — deja la app en la ruta antigua y regístralo, en vez de arriesgarte a un estado a medio migrar sin red de seguridad.
Lo que cuesta
SQLCipher no es gratis. En un dispositivo de gama media, espera un sobrecoste de entre el 5 % y el 15 % en el rendimiento de lectura/escritura frente a SQLite normal, sobre todo por el cifrado AES-256 por página y la verificación HMAC adicional al leer. Para una app de presupuesto o seguimiento de suscripciones con unos pocos miles de filas, esto es imperceptible — consultas que tardaban 8 ms tardan 9. Para cualquier cosa que haga importaciones masivas de decenas de miles de filas en una sola transacción, mide el rendimiento antes de publicar; ahí es donde el sobrecoste se acumula lo suficiente para notarse.
El otro coste real es la recuperación. Si pierdes la contraseña envuelta — un restablecimiento del Keystore tras una restauración de fábrica, un fallo del sistema operativo que borra las claves propias de la app — la base de datos cifrada es irrecuperable por diseño. No hay una puerta trasera que añadir; eso es lo que significa “cifrado”. Combina esto con una exportación sin cifrar iniciada por el usuario (CSV, JSON en texto plano) como la verdadera estrategia de copia de seguridad, para que “se perdió la clave del Keystore” sea una molestia, no una pérdida de datos permanente.
Qué merece la pena cifrar de verdad
No todas las apps local-first necesitan esto. La pregunta que vale la pena hacerse antes de recurrir a SQLCipher: ¿qué revela el dato si el archivo en bruto se filtra? El registro de marcas de tiempo de una app de hidratación es de bajo riesgo. Las notas de transacciones de una app de presupuesto, o el historial de dosis de un registro de medicación, no lo son — son el tipo de cosa que una política de privacidad promete, y “el archivo estaba cifrado en AES-256 en reposo” es una promesa que SQLCipher realmente te permite cumplir, no solo escribir.
// Lecturas relacionadas
Más del diario
Construir Granyn: un control de presupuesto sin inicio de sesión bancario, en tres tablas
Cómo Granyn controla el gasto en varias divisas y detecta facturas recurrentes sin vincular una sola cuenta bancaria — el esquema Room detrás de todo, y las concesiones que exige.
Android local-first en 2026: SQLite, Room y mantener los datos del usuario en el dispositivo
Una guía de 2026 para construir apps Android local-first con Room y SQLite — diseño de esquemas, migraciones, WAL, exportaciones y cuándo (y cuándo no) añadir sincronización.
Room TypeConverters en 2026: cómo guardar enums, fechas y listas sin corromper tu esquema
Una guía práctica sobre los TypeConverters de Room en Android — enums, Instant/LocalDate y listas — además de los errores que convierten un converter en un bug silencioso de corrupción de datos.