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:
committed by
GitHub
parent
4cafdae22d
commit
1e00353f29
+11
-5
@@ -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
-3
@@ -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
|
||||
+32
-8
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -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
|
||||
}
|
||||
+16
-10
@@ -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) }
|
||||
}
|
||||
}
|
||||
+45
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+16
-3
@@ -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
|
||||
+16
-3
@@ -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
|
||||
+1
-1
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user