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
@@ -5,9 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.libraries.matrix.impl package io.element.android.libraries.androidutils.crypto
import okio.ByteString.Companion.decodeHex
/** /**
* Represents a client secret used to encrypt/decrypt data from databases, which can be either a passphrase or a raw key. * Represents a client secret used to encrypt/decrypt data from databases, which can be either a passphrase or a raw key.
@@ -17,7 +15,7 @@ sealed interface ClientSecret {
* A passphrase that can be used to derive a key for encryption/decryption. * A passphrase that can be used to derive a key for encryption/decryption.
*/ */
data class Passphrase(val value: String) : ClientSecret { data class Passphrase(val value: String) : ClientSecret {
override fun formattedAsString(): String = toString() override fun formattedAsString(): String = value
} }
/** /**
@@ -57,10 +55,18 @@ sealed interface ClientSecret {
val regex = Regex("^x'([0-9a-fA-F]+)'$") val regex = Regex("^x'([0-9a-fA-F]+)'$")
val rawKeyMatch = regex.matchEntire(secret) val rawKeyMatch = regex.matchEntire(secret)
return if (rawKeyMatch != null) { return if (rawKeyMatch != null) {
RawKey(rawKeyMatch.groupValues[1].decodeHex().toByteArray()) RawKey(rawKeyMatch.groupValues[1].hexToByteArray())
} else { } else {
Passphrase(secret) Passphrase(secret)
} }
} }
/**
* Create a [ClientSecret] from raw bytes, which will be treated as a raw key.
*/
fun fromRawBytes(bytes: ByteArray): ClientSecret = when (bytes.size) {
32 -> RawKey(bytes)
else -> Passphrase(bytes.toHexString())
}
} }
} }
@@ -16,7 +16,9 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.cachestore.impl.CacheDatabase import io.element.android.libraries.cachestore.impl.CacheDatabase
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.encrypteddb.SqlCipherDriverFactory import io.element.encrypteddb.SqlCipherDriverFactory
import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider import io.element.encrypteddb.passphrase.RandomDatabaseSecretProvider
import io.element.encrypteddb.utils.ReplaceDatabaseKey
import timber.log.Timber
@BindingContainer @BindingContainer
@ContributesTo(AppScope::class) @ContributesTo(AppScope::class)
@@ -35,9 +37,20 @@ object CacheStoreModule {
parentDir.mkdirs() parentDir.mkdirs()
} }
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile) val rekeyMigrationVersion = 2L
val passphraseProvider = RandomDatabaseSecretProvider(context, secretFile)
val driver = SqlCipherDriverFactory(passphraseProvider) val driver = SqlCipherDriverFactory(passphraseProvider)
.create(CacheDatabase.Schema, "$name.db", context) .create(
schema = CacheDatabase.Schema,
name = "$name.db",
context = context
) { db, oldVersion, newVersion ->
Timber.d("Migrating $name database from version $oldVersion to $newVersion")
if (rekeyMigrationVersion in oldVersion..newVersion) {
ReplaceDatabaseKey(passphraseProvider).replaceKey(name, db)
}
}
return CacheDatabase(driver) return CacheDatabase(driver)
} }
} }
@@ -0,0 +1,8 @@
-- Migrate DB from version 1
-- Initial schema for CacheData table
CREATE TABLE IF NOT EXISTS CacheData (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL,
updatedAt INTEGER NOT NULL
);
@@ -0,0 +1,2 @@
-- Migrate DB from version 2
-- Dummy migration to trigger a re-key
@@ -9,30 +9,54 @@
package io.element.encrypteddb package io.element.encrypteddb
import android.content.Context import android.content.Context
import androidx.sqlite.db.SupportSQLiteDatabase
import app.cash.sqldelight.db.QueryResult import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.android.AndroidSqliteDriver 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 import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
/** /**
* Creates an encrypted version of the [SqlDriver] using SQLCipher's [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( class SqlCipherDriverFactory(
private val passphraseProvider: PassphraseProvider, private val databaseSecretProvider: DatabaseSecretProvider,
) { ) {
companion object {
init {
System.loadLibrary("sqlcipher")
}
}
/** /**
* Returns a valid [SqlDriver] with SQLCipher support. * Returns a valid [SqlDriver] with SQLCipher support.
* @param schema The SQLite DB schema. * @param schema The SQLite DB schema.
* @param name The name of the database to create. * @param name The name of the database to create.
* @param context Android [Context], used to instantiate the driver. * @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 { fun create(
System.loadLibrary("sqlcipher") schema: SqlSchema<QueryResult.Value<Unit>>,
val passphrase = passphraseProvider.getPassphrase() name: String,
val factory = SupportOpenHelperFactory(passphrase) context: Context,
return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory) 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 package io.element.encrypteddb.passphrase
import io.element.android.libraries.androidutils.crypto.ClientSecret
/** /**
* An abstraction to implement secure providers for SQLCipher passphrases. * 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 package io.element.encrypteddb.passphrase
import android.content.Context import android.content.Context
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.encrypteddb.crypto.EncryptedFile import io.element.encrypteddb.crypto.EncryptedFile
import java.io.File import java.io.File
import java.security.SecureRandom 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 context Android [Context], used by [EncryptedFile] for cryptographic operations.
* @param file Destination file where the key will be stored. * @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 context: Context,
private val file: File, private val file: File,
private val secretSize: Int = 256, private val secretSizeBytes: Int = 32,
) : PassphraseProvider { ) : DatabaseSecretProvider {
override fun getPassphrase(): ByteArray { override fun getSecret(): ClientSecret {
val encryptedFile = EncryptedFile(context, file) val encryptedFile = EncryptedFile(context, file)
return if (!file.exists()) { val bytes = if (!file.exists()) {
val secret = generateSecret() val secret = generateSecret()
encryptedFile.openFileOutput().use { it.write(secret) } encryptedFile.openFileOutput().use { it.write(secret) }
secret secret
} else { } else {
encryptedFile.openFileInput().use { it.readBytes() } encryptedFile.openFileInput().use { it.readBytes() }
} }
return ClientSecret.fromRawBytes(bytes)
}
override fun reset(): Boolean {
return file.delete()
} }
private fun generateSecret(): ByteArray { private fun generateSecret(): ByteArray {
val buffer = ByteArray(size = secretSize) // Generate a random secret of the specified size using a secure random generator.
SecureRandom().nextBytes(buffer) return ByteArray(size = secretSizeBytes)
return buffer .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.")
}
}
}
@@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.impl package io.element.android.libraries.matrix.impl
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.ByteUnit import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.data.megaBytes import io.element.android.libraries.core.data.megaBytes
@@ -12,6 +12,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
@@ -30,7 +31,6 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.ClientSecret
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
@@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.keys
import android.util.Base64 import android.util.Base64
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.impl.ClientSecret import io.element.android.libraries.androidutils.crypto.ClientSecret
import java.security.SecureRandom import java.security.SecureRandom
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@@ -8,7 +8,7 @@
package io.element.android.libraries.matrix.impl.keys package io.element.android.libraries.matrix.impl.keys
import io.element.android.libraries.matrix.impl.ClientSecret import io.element.android.libraries.androidutils.crypto.ClientSecret
private const val PASSPHRASE_SIZE = 256 private const val PASSPHRASE_SIZE = 256
private const val KEY_SIZE = 32 private const val KEY_SIZE = 32
@@ -7,9 +7,9 @@
package io.element.android.libraries.matrix.impl.storage package io.element.android.libraries.matrix.impl.storage
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.data.ByteUnit import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.data.megaBytes import io.element.android.libraries.core.data.megaBytes
import io.element.android.libraries.matrix.impl.ClientSecret
import io.element.android.libraries.matrix.impl.paths.SessionPaths import io.element.android.libraries.matrix.impl.paths.SessionPaths
import org.matrix.rustcomponents.sdk.ClientBuilder import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.SqliteStoreBuilder as SdkSqliteStoreBuilder import org.matrix.rustcomponents.sdk.SqliteStoreBuilder as SdkSqliteStoreBuilder
@@ -8,7 +8,7 @@
package io.element.android.libraries.matrix.impl.auth package io.element.android.libraries.matrix.impl.auth
import io.element.android.libraries.matrix.impl.ClientSecret import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.matrix.impl.keys.SecretGenerator import io.element.android.libraries.matrix.impl.keys.SecretGenerator
import io.element.android.libraries.matrix.test.A_PASSPHRASE import io.element.android.libraries.matrix.test.A_PASSPHRASE
@@ -9,7 +9,7 @@
package io.element.android.libraries.matrix.impl.keys package io.element.android.libraries.matrix.impl.keys
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.impl.ClientSecret import io.element.android.libraries.androidutils.crypto.ClientSecret
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
@@ -7,7 +7,7 @@
package io.element.android.libraries.matrix.impl.storage package io.element.android.libraries.matrix.impl.storage
import io.element.android.libraries.matrix.impl.ClientSecret import io.element.android.libraries.androidutils.crypto.ClientSecret
import org.matrix.rustcomponents.sdk.ClientBuilder import org.matrix.rustcomponents.sdk.ClientBuilder
class FakeSqliteStoreBuilder : SqliteStoreBuilder { class FakeSqliteStoreBuilder : SqliteStoreBuilder {
@@ -17,7 +17,9 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.impl.PushDatabase import io.element.android.libraries.push.impl.PushDatabase
import io.element.encrypteddb.SqlCipherDriverFactory import io.element.encrypteddb.SqlCipherDriverFactory
import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider import io.element.encrypteddb.passphrase.RandomDatabaseSecretProvider
import io.element.encrypteddb.utils.ReplaceDatabaseKey
import timber.log.Timber
@BindingContainer @BindingContainer
@ContributesTo(AppScope::class) @ContributesTo(AppScope::class)
@@ -36,9 +38,20 @@ object PushHistoryModule {
parentDir.mkdirs() parentDir.mkdirs()
} }
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile) val rekeyMigrationVersion = 2L
val passphraseProvider = RandomDatabaseSecretProvider(context, secretFile)
val driver = SqlCipherDriverFactory(passphraseProvider) val driver = SqlCipherDriverFactory(passphraseProvider)
.create(PushDatabase.Schema, "$name.db", context) .create(
schema = PushDatabase.Schema,
name = "$name.db",
context = context
) { db, oldVersion, newVersion ->
Timber.d("Migrating $name database from version $oldVersion to $newVersion")
if (rekeyMigrationVersion in oldVersion..newVersion) {
ReplaceDatabaseKey(passphraseProvider).replaceKey(name, db)
}
}
return PushDatabase(driver) return PushDatabase(driver)
} }
} }
@@ -0,0 +1,2 @@
-- Migrate DB from version 2
-- Dummy migration to trigger a re-key
@@ -17,7 +17,9 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.sessionstorage.impl.SessionDatabase import io.element.android.libraries.sessionstorage.impl.SessionDatabase
import io.element.encrypteddb.SqlCipherDriverFactory import io.element.encrypteddb.SqlCipherDriverFactory
import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider import io.element.encrypteddb.passphrase.RandomDatabaseSecretProvider
import io.element.encrypteddb.utils.ReplaceDatabaseKey
import timber.log.Timber
@BindingContainer @BindingContainer
@ContributesTo(AppScope::class) @ContributesTo(AppScope::class)
@@ -36,9 +38,20 @@ object SessionStorageModule {
parentDir.mkdirs() parentDir.mkdirs()
} }
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile) val rekeyMigrationVersion = 11L
val passphraseProvider = RandomDatabaseSecretProvider(context, secretFile)
val driver = SqlCipherDriverFactory(passphraseProvider) val driver = SqlCipherDriverFactory(passphraseProvider)
.create(SessionDatabase.Schema, "$name.db", context) .create(
schema = SessionDatabase.Schema,
name = "$name.db",
context = context,
) { db, oldVersion, newVersion ->
Timber.d("Migrating $name database from version $oldVersion to $newVersion")
if (rekeyMigrationVersion in oldVersion..newVersion) {
ReplaceDatabaseKey(passphraseProvider).replaceKey(name, db)
}
}
return SessionDatabase(driver) return SessionDatabase(driver)
} }
} }
@@ -0,0 +1,2 @@
-- Migrate DB from version 11
-- Dummy migration to trigger a re-key
@@ -57,7 +57,7 @@ class DefaultAnalyticsColdStartWatcher(
override fun onRoomListVisible() { override fun onRoomListVisible() {
if (isColdStart.getAndSet(false)) { if (isColdStart.getAndSet(false)) {
analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart) { analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart) {
Timber.d("Room list is visible, finishing cold start check. Elapsed: ${it.duration}") Timber.d("Room list is visible, finishing cold start check after ${it.duration}")
} }
} }
} }