diff --git a/libraries/androidutils/build.gradle.kts b/libraries/androidutils/build.gradle.kts index 8bc0d8e421..b8660696d7 100644 --- a/libraries/androidutils/build.gradle.kts +++ b/libraries/androidutils/build.gradle.kts @@ -27,5 +27,6 @@ dependencies { implementation(libs.timber) implementation(libs.androidx.corektx) implementation(libs.androidx.activity.activity) + implementation(libs.androidx.security.crypto) implementation(projects.libraries.core) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/EncryptedFileFactory.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/EncryptedFileFactory.kt new file mode 100644 index 0000000000..815c5fad6e --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/EncryptedFileFactory.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.androidutils.file + +import android.content.Context +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKeys +import java.io.File + +class EncryptedFileFactory( + private val context: Context, +) { + fun create(file: File): EncryptedFile { + // We need to use the same key for all the encrypted files. + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + return EncryptedFile.Builder( + file, + context, + masterKeyAlias, + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + ).build() + } +} diff --git a/libraries/encrypted-db/build.gradle.kts b/libraries/encrypted-db/build.gradle.kts index e904072898..1921ed335f 100644 --- a/libraries/encrypted-db/build.gradle.kts +++ b/libraries/encrypted-db/build.gradle.kts @@ -34,4 +34,6 @@ dependencies { implementation(libs.sqlcipher) implementation(libs.sqlite) implementation(libs.androidx.security.crypto) + + implementation(projects.libraries.androidutils) } diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt index 3367c777d0..dd09188bc4 100644 --- a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt +++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt @@ -18,6 +18,7 @@ package io.element.encrypteddb.passphrase import android.content.Context import androidx.security.crypto.EncryptedFile +import io.element.android.libraries.androidutils.file.EncryptedFileFactory import java.io.File import java.security.SecureRandom @@ -25,23 +26,16 @@ import java.security.SecureRandom * Provides a secure passphrase 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 alias Alias of the key used to encrypt & decrypt the [EncryptedFile]'s contents. * @param secretSize Length of the generated secret. */ class RandomSecretPassphraseProvider( private val context: Context, private val file: File, - private val alias: String, private val secretSize: Int = 256, ) : PassphraseProvider { override fun getPassphrase(): ByteArray { - val encryptedFile = EncryptedFile.Builder( - file, - context, - alias, - EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB - ).build() + val encryptedFile = EncryptedFileFactory(context).create(file) return if (!file.exists()) { val secret = generateSecret() encryptedFile.openFileOutput().use { it.write(secret) } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 3724832e6a..ca280be6ba 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(libs.androidx.corektx) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.security.crypto) implementation(libs.network.retrofit) implementation(libs.serialization.json) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt index c8ba481323..d6135f28b0 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt @@ -17,60 +17,75 @@ package io.element.android.libraries.push.impl.notifications import android.content.Context +import io.element.android.libraries.androidutils.file.EncryptedFileFactory +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.push.impl.log.notificationLoggerTag import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import timber.log.Timber import java.io.File -import java.io.FileOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream import javax.inject.Inject -// TODO Multi-account -private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache" -private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr" +private const val ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY = "im.vector.notifications.cache" +private const val FILE_NAME = "notifications.bin" + +private val loggerTag = LoggerTag("NotificationEventPersistence", notificationLoggerTag) class NotificationEventPersistence @Inject constructor( @ApplicationContext private val context: Context, - // private val matrix: Matrix, ) { + private val file by lazy { + deleteLegacyFileIfAny() + context.getDatabasePath(FILE_NAME) + } + + private val encryptedFile by lazy { + EncryptedFileFactory(context).create(file) + } fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue { - try { - val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) - if (file.exists()) { - file.inputStream().use { - val events: ArrayList? = null // TODO EAx matrix.secureStorageService().loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE) - if (events != null) { - return factory(events) + val rawEvents: ArrayList? = file + .takeIf { it.exists() } + ?.let { + try { + encryptedFile.openFileInput().use { fis -> + ObjectInputStream(fis).use { ois -> + @Suppress("UNCHECKED_CAST") + ois.readObject() as? ArrayList + } + }.also { + Timber.tag(loggerTag.value).d("Deserializing ${it?.size} NotifiableEvent(s)") } + } catch (e: Throwable) { + Timber.tag(loggerTag.value).e(e, "## Failed to load cached notification info") + null } } - } catch (e: Throwable) { - Timber.e(e, "## Failed to load cached notification info") - } - return factory(emptyList()) + return factory(rawEvents.orEmpty()) } fun persistEvents(queuedEvents: NotificationEventQueue) { - if (queuedEvents.isEmpty()) { - deleteCachedRoomNotifications(context) - return - } + Timber.tag(loggerTag.value).d("Serializing ${queuedEvents.rawEvents().size} NotifiableEvent(s)") + // Always delete file before writing, or encryptedFile.openFileOutput() will throw + file.delete() + if (queuedEvents.isEmpty()) return try { - val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) - if (!file.exists()) file.createNewFile() - FileOutputStream(file).use { - // TODO EAx - // matrix.secureStorageService().securelyStoreObject(queuedEvents.rawEvents(), KEY_ALIAS_SECRET_STORAGE, it) + encryptedFile.openFileOutput().use { fos -> + ObjectOutputStream(fos).use { oos -> + oos.writeObject(queuedEvents.rawEvents()) + } } } catch (e: Throwable) { - Timber.e(e, "## Failed to save cached notification info") + Timber.tag(loggerTag.value).e(e, "## Failed to save cached notification info") } } - private fun deleteCachedRoomNotifications(context: Context) { - val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) - if (file.exists()) { - file.delete() + private fun deleteLegacyFileIfAny() { + tryOrNull { + File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY).delete() } } } diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index 08dc581018..b554bb5d8f 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -30,6 +30,7 @@ anvil { dependencies { implementation(libs.dagger) + implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.encryptedDb) api(projects.libraries.sessionStorage.api) diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt index b101fc39b4..052943388e 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt @@ -35,7 +35,7 @@ object SessionStorageModule { fun provideMatrixDatabase(@ApplicationContext context: Context): SessionDatabase { val name = "session_database" val secretFile = context.getDatabasePath("$name.key") - val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile, name) + val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile) val driver = SqlCipherDriverFactory(passphraseProvider) .create(SessionDatabase.Schema, "$name.db", context) return SessionDatabase(driver)