Persist notification data. Note that it will break the key storage for the session database.

This commit is contained in:
Benoit Marty
2023-04-04 14:09:14 +02:00
committed by Benoit Marty
parent 2696348d46
commit c0ef4804a1
8 changed files with 90 additions and 39 deletions

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -34,4 +34,6 @@ dependencies {
implementation(libs.sqlcipher)
implementation(libs.sqlite)
implementation(libs.androidx.security.crypto)
implementation(projects.libraries.androidutils)
}

View File

@@ -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) }

View File

@@ -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)

View File

@@ -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<NotifiableEvent>) -> NotificationEventQueue): NotificationEventQueue {
try {
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (file.exists()) {
file.inputStream().use {
val events: ArrayList<NotifiableEvent>? = null // TODO EAx matrix.secureStorageService().loadSecureSecret(it, KEY_ALIAS_SECRET_STORAGE)
if (events != null) {
return factory(events)
val rawEvents: ArrayList<NotifiableEvent>? = file
.takeIf { it.exists() }
?.let {
try {
encryptedFile.openFileInput().use { fis ->
ObjectInputStream(fis).use { ois ->
@Suppress("UNCHECKED_CAST")
ois.readObject() as? ArrayList<NotifiableEvent>
}
}.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()
}
}
}

View File

@@ -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)

View File

@@ -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)