Avoid SQLCipher key derivation (#6774)

* Avoid SQLCipher key derivation by manually providing the generated as a raw key: this needs a series of DB re-key operations, performed as part of a 'dummy' DB migration

* Set key generation length to 32 bytes in `RandomSecretPassphraseProvider`.

* Use `ClientSecret` for handling the keys for SQLCipher. Move `ClientSecret` to `:libraries:androidutils` so it's shared.
This commit is contained in:
Jorge Martin Espinosa
2026-05-27 17:25:51 +02:00
committed by GitHub
parent 4cafdae22d
commit 1e00353f29
21 changed files with 185 additions and 43 deletions
@@ -9,30 +9,54 @@
package io.element.encrypteddb
import android.content.Context
import androidx.sqlite.db.SupportSQLiteDatabase
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import io.element.encrypteddb.passphrase.PassphraseProvider
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.encrypteddb.passphrase.DatabaseSecretProvider
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
/**
* Creates an encrypted version of the [SqlDriver] using SQLCipher's [SupportOpenHelperFactory].
* @param passphraseProvider Provides the passphrase needed to use the SQLite database with SQLCipher.
* @param databaseSecretProvider Provides the passphrase needed to use the SQLite database with SQLCipher.
*/
class SqlCipherDriverFactory(
private val passphraseProvider: PassphraseProvider,
private val databaseSecretProvider: DatabaseSecretProvider,
) {
companion object {
init {
System.loadLibrary("sqlcipher")
}
}
/**
* Returns a valid [SqlDriver] with SQLCipher support.
* @param schema The SQLite DB schema.
* @param name The name of the database to create.
* @param context Android [Context], used to instantiate the driver.
* @param onUpgradeCallback Optional callback to handle database upgrades, which will be called in the [AndroidSqliteDriver.Callback.onUpgrade] method.
*/
fun create(schema: SqlSchema<QueryResult.Value<Unit>>, name: String, context: Context): SqlDriver {
System.loadLibrary("sqlcipher")
val passphrase = passphraseProvider.getPassphrase()
val factory = SupportOpenHelperFactory(passphrase)
return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory)
fun create(
schema: SqlSchema<QueryResult.Value<Unit>>,
name: String,
context: Context,
onUpgradeCallback: ((driver: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) -> Unit)? = null,
): SqlDriver {
val key = when (val secret = databaseSecretProvider.getSecret()) {
// For a raw key, we need the blob representation (`x'...'`) as bytes
is ClientSecret.RawKey -> secret.formattedAsString().toByteArray()
// For a passphrase, we need the bytes for the hex string representation
is ClientSecret.Passphrase -> secret.formattedAsString().hexToByteArray()
}
val factory = SupportOpenHelperFactory(key)
return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory, callback = object : AndroidSqliteDriver.Callback(
schema
) {
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
onUpgradeCallback?.invoke(db, oldVersion, newVersion)
}
})
}
}
@@ -8,12 +8,19 @@
package io.element.encrypteddb.passphrase
import io.element.android.libraries.androidutils.crypto.ClientSecret
/**
* An abstraction to implement secure providers for SQLCipher passphrases.
*/
interface PassphraseProvider {
interface DatabaseSecretProvider {
/**
* Returns a passphrase for SQLCipher in [ByteArray] format.
* Returns a secret for SQLCipher.
*/
fun getPassphrase(): ByteArray
fun getSecret(): ClientSecret
/**
* Resets the passphrase, for example by deleting the persisted secret. Returns `true` if the reset was successful, `false` otherwise.
*/
fun reset(): Boolean
}
@@ -9,35 +9,41 @@
package io.element.encrypteddb.passphrase
import android.content.Context
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.encrypteddb.crypto.EncryptedFile
import java.io.File
import java.security.SecureRandom
/**
* Provides a secure passphrase for SQLCipher by generating a random secret and storing it into an [EncryptedFile].
* Provides a secure secret for SQLCipher by generating a random secret and storing it into an [EncryptedFile].
* @param context Android [Context], used by [EncryptedFile] for cryptographic operations.
* @param file Destination file where the key will be stored.
* @param secretSize Length of the generated secret.
* @param secretSizeBytes Length of the generated secret.
*/
class RandomSecretPassphraseProvider(
class RandomDatabaseSecretProvider(
private val context: Context,
private val file: File,
private val secretSize: Int = 256,
) : PassphraseProvider {
override fun getPassphrase(): ByteArray {
private val secretSizeBytes: Int = 32,
) : DatabaseSecretProvider {
override fun getSecret(): ClientSecret {
val encryptedFile = EncryptedFile(context, file)
return if (!file.exists()) {
val bytes = if (!file.exists()) {
val secret = generateSecret()
encryptedFile.openFileOutput().use { it.write(secret) }
secret
} else {
encryptedFile.openFileInput().use { it.readBytes() }
}
return ClientSecret.fromRawBytes(bytes)
}
override fun reset(): Boolean {
return file.delete()
}
private fun generateSecret(): ByteArray {
val buffer = ByteArray(size = secretSize)
SecureRandom().nextBytes(buffer)
return buffer
// Generate a random secret of the specified size using a secure random generator.
return ByteArray(size = secretSizeBytes)
.also { SecureRandom().nextBytes(it) }
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.encrypteddb.utils
import androidx.sqlite.db.SupportSQLiteDatabase
import io.element.encrypteddb.passphrase.RandomDatabaseSecretProvider
import timber.log.Timber
/**
* A utility class to replace the encryption key of an existing SQLCipher database.
* This is used during database migrations when we want to change the encryption key.
*
* @param databaseSecretProvider The provider for generating new secrets.
*/
class ReplaceDatabaseKey(
private val databaseSecretProvider: RandomDatabaseSecretProvider
) {
fun replaceKey(name: String, database: SupportSQLiteDatabase) {
Timber.d("Re-keying database $name")
// Reset the passphrase provider to generate a new passphrase
databaseSecretProvider.reset()
// Get the new secret and convert it to the format expected by SQLCipher
val newSecret = databaseSecretProvider.getSecret()
val key = newSecret.formattedAsString()
// Use the PRAGMA rekey command to change the encryption key of the database
database.query("PRAGMA rekey = \"$key\";").close()
// Verify that the database can be accessed with the new key by running a simple query
val result = database.query("select count(*) from sqlite_master").use { cursor ->
if (cursor.moveToNext()) cursor.getLong(0) else -1L
}
if (result >= 0) {
Timber.d("Re-keying database $name completed")
} else {
Timber.e("Re-keying database $name didn't work as expected.")
}
}
}