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.
*/
package io.element.android.libraries.matrix.impl
import okio.ByteString.Companion.decodeHex
package io.element.android.libraries.androidutils.crypto
/**
* 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.
*/
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 rawKeyMatch = regex.matchEntire(secret)
return if (rawKeyMatch != null) {
RawKey(rawKeyMatch.groupValues[1].decodeHex().toByteArray())
RawKey(rawKeyMatch.groupValues[1].hexToByteArray())
} else {
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.di.annotations.ApplicationContext
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
@ContributesTo(AppScope::class)
@@ -35,9 +37,20 @@ object CacheStoreModule {
parentDir.mkdirs()
}
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile)
val rekeyMigrationVersion = 2L
val passphraseProvider = RandomDatabaseSecretProvider(context, secretFile)
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)
}
}
@@ -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
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.")
}
}
}
@@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.impl
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.data.ByteUnit
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.SingleIn
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.extensions.mapFailure
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.verification.SessionVerifiedStatus
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.auth.qrlogin.QrErrorMapper
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 dev.zacsweers.metro.AppScope
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
@ContributesBinding(AppScope::class)
@@ -8,7 +8,7 @@
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 KEY_SIZE = 32
@@ -7,9 +7,9 @@
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.megaBytes
import io.element.android.libraries.matrix.impl.ClientSecret
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.SqliteStoreBuilder as SdkSqliteStoreBuilder
@@ -8,7 +8,7 @@
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.test.A_PASSPHRASE
@@ -9,7 +9,7 @@
package io.element.android.libraries.matrix.impl.keys
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.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -7,7 +7,7 @@
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
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.push.impl.PushDatabase
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
@ContributesTo(AppScope::class)
@@ -36,9 +38,20 @@ object PushHistoryModule {
parentDir.mkdirs()
}
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile)
val rekeyMigrationVersion = 2L
val passphraseProvider = RandomDatabaseSecretProvider(context, secretFile)
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)
}
}
@@ -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.sessionstorage.impl.SessionDatabase
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
@ContributesTo(AppScope::class)
@@ -36,9 +38,20 @@ object SessionStorageModule {
parentDir.mkdirs()
}
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile)
val rekeyMigrationVersion = 11L
val passphraseProvider = RandomDatabaseSecretProvider(context, secretFile)
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)
}
}
@@ -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() {
if (isColdStart.getAndSet(false)) {
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}")
}
}
}