From 09fbd5d4e91692e3896dad06d9f23cc7a6ce3ee1 Mon Sep 17 00:00:00 2001 From: joshua-livefront <139182194+joshua-livefront@users.noreply.github.com> Date: Thu, 14 Dec 2023 12:25:35 -0500 Subject: [PATCH] BIT-279: Adding password history data layer (#387) --- app/build.gradle.kts | 2 + .../repository/model/LocalDataState.kt | 34 ++++++ .../disk/PasswordHistoryDiskSource.kt | 25 ++++ .../disk/PasswordHistoryDiskSourceImpl.kt | 27 +++++ .../datasource/disk/dao/PasswordHistoryDao.kt | 33 ++++++ .../disk/database/PasswordHistoryDatabase.kt | 18 +++ .../datasource/disk/di/GeneratorDiskModule.kt | 32 +++++ .../disk/entity/PasswordHistoryEntity.kt | 49 ++++++++ .../repository/GeneratorRepository.kt | 18 +++ .../repository/GeneratorRepositoryImpl.kt | 94 +++++++++++++++ .../di/GeneratorRepositoryModule.kt | 6 + .../vault/datasource/sdk/VaultSdkSource.kt | 16 +++ .../datasource/sdk/VaultSdkSourceImpl.kt | 16 +++ .../vault/datasource/sdk/di/VaultSdkModule.kt | 1 + .../disk/PasswordHistoryDiskSourceTest.kt | 64 ++++++++++ .../disk/dao/FakePasswordHistoryDao.kt | 36 ++++++ .../disk/entity/PasswordHistoryEntityTest.kt | 43 +++++++ .../repository/GeneratorRepositoryTest.kt | 110 +++++++++++++++++- .../util/FakeGeneratorRepository.kt | 20 ++++ .../datasource/sdk/VaultSdkSourceTest.kt | 51 ++++++++ 20 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/LocalDataState.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSource.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSourceImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/dao/PasswordHistoryDao.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/database/PasswordHistoryDatabase.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/entity/PasswordHistoryEntity.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSourceTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/dao/FakePasswordHistoryDao.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/entity/PasswordHistoryEntityTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 011e66100f..b638ec29f7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -160,6 +160,8 @@ koverReport { "com.x8bit.bitwarden.MainActivity*", // Empty Composables "com.x8bit.bitwarden.ui.platform.feature.splash.SplashScreenKt", + // Databases + "*.database.*Database", ) packages( // Dependency injection diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/LocalDataState.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/LocalDataState.kt new file mode 100644 index 0000000000..fafffb5635 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/LocalDataState.kt @@ -0,0 +1,34 @@ +package com.x8bit.bitwarden.data.platform.repository.model + +/** + * A local data state used for handling local data in the repository layer. + */ +sealed class LocalDataState { + + /** + * Data that is being wrapped by [LocalDataState]. + */ + abstract val data: T? + + /** + * Loading state representing the absence of data. + */ + data object Loading : LocalDataState() { + override val data: Nothing? get() = null + } + + /** + * Loaded state representing the availability of data. + */ + data class Loaded( + override val data: T, + ) : LocalDataState() + + /** + * Error state that may or may not have data available. + */ + data class Error( + val error: Throwable, + override val data: T? = null, + ) : LocalDataState() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSource.kt new file mode 100644 index 0000000000..f449716e3e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSource.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.data.tools.generator.datasource.disk + +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity +import kotlinx.coroutines.flow.Flow + +/** + * Primary access point for disk information related to password history. + */ +interface PasswordHistoryDiskSource { + + /** + * Retrieves all password history items from the data source as a Flow. + */ + fun getPasswordHistoriesForUser(userId: String): Flow> + + /** + * Inserts a generated history item into the data source. + */ + suspend fun insertPasswordHistory(passwordHistoryEntity: PasswordHistoryEntity) + + /** + * Clears all password history items from the data source. + */ + suspend fun clearPasswordHistories(userId: String) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSourceImpl.kt new file mode 100644 index 0000000000..af9fcba782 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSourceImpl.kt @@ -0,0 +1,27 @@ +package com.x8bit.bitwarden.data.tools.generator.datasource.disk + +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao.PasswordHistoryDao +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity +import kotlinx.coroutines.flow.Flow + +/** + * Primary implementation of [PasswordHistoryDiskSource]. + */ +class PasswordHistoryDiskSourceImpl( + private val passwordHistoryDao: PasswordHistoryDao, +) : PasswordHistoryDiskSource { + + override fun getPasswordHistoriesForUser(userId: String): Flow> { + return passwordHistoryDao.getPasswordHistoriesForUserAsFlow(userId) + } + + override suspend fun insertPasswordHistory( + passwordHistoryEntity: PasswordHistoryEntity, + ) { + passwordHistoryDao.insertPasswordHistory(passwordHistoryEntity) + } + + override suspend fun clearPasswordHistories(userId: String) { + passwordHistoryDao.clearPasswordHistoriesForUser(userId) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/dao/PasswordHistoryDao.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/dao/PasswordHistoryDao.kt new file mode 100644 index 0000000000..236aa9d2f8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/dao/PasswordHistoryDao.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity +import kotlinx.coroutines.flow.Flow + +/** + * Provides methods for inserting, retrieving, and deleting passcode history items + * from the database, interacting with the [PasswordHistoryEntity] entity. + */ +@Dao +interface PasswordHistoryDao { + + /** + * Inserts a password history item into the database. + */ + @Insert + suspend fun insertPasswordHistory(passwordHistory: PasswordHistoryEntity) + + /** + * Retrieves all password history items for a specific user from the database as a Flow. + */ + @Query("SELECT * FROM password_history WHERE userId = :userId") + fun getPasswordHistoriesForUserAsFlow(userId: String): Flow> + + /** + * Clears all password history items from the database for a specific user. + */ + @Query("DELETE FROM password_history WHERE userId = :userId") + suspend fun clearPasswordHistoriesForUser(userId: String) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/database/PasswordHistoryDatabase.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/database/PasswordHistoryDatabase.kt new file mode 100644 index 0000000000..31dd21530f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/database/PasswordHistoryDatabase.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.tools.generator.datasource.disk.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao.PasswordHistoryDao +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity + +/** + * Room database for storing passcode history. + */ +@Database(entities = [PasswordHistoryEntity::class], version = 1) +abstract class PasswordHistoryDatabase : RoomDatabase() { + + /** + * Provides the DAO for accessing passcode history data. + */ + abstract fun passwordHistoryDao(): PasswordHistoryDao +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/di/GeneratorDiskModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/di/GeneratorDiskModule.kt index f19e490bab..fa3d165558 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/di/GeneratorDiskModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/di/GeneratorDiskModule.kt @@ -1,8 +1,14 @@ package com.x8bit.bitwarden.data.tools.generator.datasource.disk.di +import android.app.Application import android.content.SharedPreferences +import androidx.room.Room +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao.PasswordHistoryDao +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.database.PasswordHistoryDatabase import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSourceImpl +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSourceImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,4 +33,30 @@ object GeneratorDiskModule { sharedPreferences = sharedPreferences, json = json, ) + + @Provides + @Singleton + fun providePasswordHistoryDiskSource( + passwordHistoryDao: PasswordHistoryDao, + ): PasswordHistoryDiskSource = PasswordHistoryDiskSourceImpl( + passwordHistoryDao = passwordHistoryDao, + ) + + @Provides + @Singleton + fun providePasswordHistoryDatabase(app: Application): PasswordHistoryDatabase { + return Room + .databaseBuilder( + context = app, + klass = PasswordHistoryDatabase::class.java, + name = "passcode_history_database", + ) + .build() + } + + @Provides + @Singleton + fun providePasswordHistoryDao(database: PasswordHistoryDatabase): PasswordHistoryDao { + return database.passwordHistoryDao() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/entity/PasswordHistoryEntity.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/entity/PasswordHistoryEntity.kt new file mode 100644 index 0000000000..02a4fcb796 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/entity/PasswordHistoryEntity.kt @@ -0,0 +1,49 @@ +package com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.bitwarden.core.PasswordHistory +import java.time.Instant + +/** + * Entity representing a generated history item in the database. + */ +@Entity(tableName = "password_history") +data class PasswordHistoryEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Int = 0, + + @ColumnInfo(name = "userId") + val userId: String, + + @ColumnInfo(name = "encrypted_password") + val encryptedPassword: String, + + @ColumnInfo(name = "generated_date_time_ms") + val generatedDateTimeMs: Long, +) + +/** + * Converts a PasswordHistory object to a GeneratedHistoryItem. + * This function is used to transform data from the SDK model to the database entity model. + */ +fun PasswordHistory.toPasswordHistoryEntity(userId: String): PasswordHistoryEntity { + return PasswordHistoryEntity( + userId = userId, + encryptedPassword = this.password, + generatedDateTimeMs = this.lastUsedDate.toEpochMilli(), + ) +} + +/** + * Converts a GeneratedHistoryItem object to a PasswordHistory. + * This function is used to transform data from the database entity model to the SDK model. + */ +fun PasswordHistoryEntity.toPasswordHistory(): PasswordHistory { + return PasswordHistory( + password = this.encryptedPassword, + lastUsedDate = Instant.ofEpochMilli(this.generatedDateTimeMs), + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt index 14f1483521..4e0e3ca9f2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepository.kt @@ -2,15 +2,23 @@ package com.x8bit.bitwarden.data.tools.generator.repository import com.bitwarden.core.PassphraseGeneratorRequest import com.bitwarden.core.PasswordGeneratorRequest +import com.bitwarden.core.PasswordHistoryView +import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions +import kotlinx.coroutines.flow.StateFlow /** * Responsible for managing generator data. */ interface GeneratorRepository { + /** + * Retrieve all stored password history items for the current user. + */ + val passwordHistoryStateFlow: StateFlow>> + /** * Attempt to generate a password. */ @@ -34,4 +42,14 @@ interface GeneratorRepository { * Save the [PasscodeGenerationOptions] for the current user. */ fun savePasscodeGenerationOptions(options: PasscodeGenerationOptions) + + /** + * Store a password history item for the current user. + */ + suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView) + + /** + * Clear all stored password history for the current user. + */ + suspend fun clearPasswordHistory() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt index 2928f25a8e..d09396f76e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryImpl.kt @@ -2,24 +2,103 @@ package com.x8bit.bitwarden.data.tools.generator.repository import com.bitwarden.core.PassphraseGeneratorRequest import com.bitwarden.core.PasswordGeneratorRequest +import com.bitwarden.core.PasswordHistoryView import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.toPasswordHistory +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.toPasswordHistoryEntity import com.x8bit.bitwarden.data.tools.generator.datasource.sdk.GeneratorSdkSource import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import javax.inject.Singleton /** * Default implementation of [GeneratorRepository]. */ +@OptIn(ExperimentalCoroutinesApi::class) @Singleton class GeneratorRepositoryImpl constructor( private val generatorSdkSource: GeneratorSdkSource, private val generatorDiskSource: GeneratorDiskSource, private val authDiskSource: AuthDiskSource, + private val vaultSdkSource: VaultSdkSource, + private val passwordHistoryDiskSource: PasswordHistoryDiskSource, ) : GeneratorRepository { + private val scope = CoroutineScope(Dispatchers.IO) + private val mutablePasswordHistoryStateFlow = + MutableStateFlow>>(LocalDataState.Loading) + + override val passwordHistoryStateFlow: StateFlow>> + get() = mutablePasswordHistoryStateFlow.asStateFlow() + + private var passwordHistoryJob: Job? = null + + init { + mutablePasswordHistoryStateFlow + .subscriptionCount + .flatMapLatest { subscriberCount -> + if (subscriberCount > 0) { + authDiskSource + .userStateFlow + .map { it?.activeUserId } + .distinctUntilChanged() + } else { + flow { awaitCancellation() } + } + } + .onEach { activeUserId -> + observePasswordHistoryForUser(activeUserId) + } + .launchIn(scope) + } + + private fun observePasswordHistoryForUser(userId: String?) { + passwordHistoryJob?.cancel() + userId ?: return + + mutablePasswordHistoryStateFlow.value = LocalDataState.Loading + + passwordHistoryJob = passwordHistoryDiskSource + .getPasswordHistoriesForUser(userId) + .map { encryptedPasswordHistoryList -> + val passwordHistories = + encryptedPasswordHistoryList.map { it.toPasswordHistory() } + vaultSdkSource + .decryptPasswordHistoryList(passwordHistories) + } + .onEach { encryptedPasswordHistoryListResult -> + encryptedPasswordHistoryListResult + .fold( + onSuccess = { + mutablePasswordHistoryStateFlow.value = LocalDataState.Loaded(it) + }, + onFailure = { + mutablePasswordHistoryStateFlow.value = LocalDataState.Error(it) + }, + ) + } + .launchIn(scope) + } + override suspend fun generatePassword( passwordGeneratorRequest: PasswordGeneratorRequest, ): GeneratedPasswordResult = @@ -49,4 +128,19 @@ class GeneratorRepositoryImpl constructor( val userId = authDiskSource.userState?.activeUserId userId?.let { generatorDiskSource.storePasscodeGenerationOptions(it, options) } } + + override suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView) { + val userId = authDiskSource.userState?.activeUserId ?: return + val encryptedPasswordHistory = vaultSdkSource + .encryptPasswordHistory(passwordHistoryView) + .getOrNull() ?: return + passwordHistoryDiskSource.insertPasswordHistory( + encryptedPasswordHistory.toPasswordHistoryEntity(userId), + ) + } + + override suspend fun clearPasswordHistory() { + val userId = authDiskSource.userState?.activeUserId ?: return + passwordHistoryDiskSource.clearPasswordHistories(userId) + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt index 7cf794a393..4844076318 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt @@ -2,9 +2,11 @@ package com.x8bit.bitwarden.data.tools.generator.repository.di import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource import com.x8bit.bitwarden.data.tools.generator.datasource.sdk.GeneratorSdkSource import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepositoryImpl +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,9 +26,13 @@ object GeneratorRepositoryModule { generatorSdkSource: GeneratorSdkSource, generatorDiskSource: GeneratorDiskSource, authDiskSource: AuthDiskSource, + vaultSdkSource: VaultSdkSource, + passwordHistoryDiskSource: PasswordHistoryDiskSource, ): GeneratorRepository = GeneratorRepositoryImpl( generatorSdkSource = generatorSdkSource, generatorDiskSource = generatorDiskSource, authDiskSource = authDiskSource, + vaultSdkSource = vaultSdkSource, + passwordHistoryDiskSource = passwordHistoryDiskSource, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index d436892e9a..ffbda5a59c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -9,6 +9,8 @@ import com.bitwarden.core.Folder import com.bitwarden.core.FolderView import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoRequest +import com.bitwarden.core.PasswordHistory +import com.bitwarden.core.PasswordHistoryView import com.bitwarden.core.Send import com.bitwarden.core.SendView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult @@ -86,4 +88,18 @@ interface VaultSdkSource { * Decrypts a list of [Folder]s returning a list of [FolderView] wrapped in a [Result]. */ suspend fun decryptFolderList(folderList: List): Result> + + /** + * Encrypts a given password history item. + */ + suspend fun encryptPasswordHistory( + passwordHistory: PasswordHistoryView, + ): Result + + /** + * Decrypts a list of password history items. + */ + suspend fun decryptPasswordHistoryList( + passwordHistoryList: List, + ): Result> } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index bb3b29e465..bec761541b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -9,10 +9,13 @@ import com.bitwarden.core.Folder import com.bitwarden.core.FolderView import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoRequest +import com.bitwarden.core.PasswordHistory +import com.bitwarden.core.PasswordHistoryView import com.bitwarden.core.Send import com.bitwarden.core.SendView import com.bitwarden.sdk.BitwardenException import com.bitwarden.sdk.ClientCrypto +import com.bitwarden.sdk.ClientPasswordHistory import com.bitwarden.sdk.ClientVault import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult @@ -24,6 +27,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResul class VaultSdkSourceImpl( private val clientVault: ClientVault, private val clientCrypto: ClientCrypto, + private val clientPasswordHistory: ClientPasswordHistory, ) : VaultSdkSource { override suspend fun initializeCrypto( request: InitUserCryptoRequest, @@ -88,4 +92,16 @@ class VaultSdkSourceImpl( override suspend fun decryptFolderList(folderList: List): Result> = runCatching { clientVault.folders().decryptList(folderList) } + + override suspend fun encryptPasswordHistory( + passwordHistory: PasswordHistoryView, + ): Result = runCatching { + clientPasswordHistory.encrypt(passwordHistory) + } + + override suspend fun decryptPasswordHistoryList( + passwordHistoryList: List, + ): Result> = runCatching { + clientPasswordHistory.decryptList(passwordHistoryList) + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/di/VaultSdkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/di/VaultSdkModule.kt index bbcb67b4c7..1a1e211fab 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/di/VaultSdkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/di/VaultSdkModule.kt @@ -24,5 +24,6 @@ object VaultSdkModule { VaultSdkSourceImpl( clientVault = client.vault(), clientCrypto = client.crypto(), + clientPasswordHistory = client.vault().passwordHistory(), ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSourceTest.kt new file mode 100644 index 0000000000..86e69ae335 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/PasswordHistoryDiskSourceTest.kt @@ -0,0 +1,64 @@ +package com.x8bit.bitwarden.data.tools.generator.datasource.disk + +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao.FakePasswordHistoryDao +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant + +class PasswordHistoryDiskSourceTest { + + private val fakePasswordHistoryDao = FakePasswordHistoryDao() + private val diskSource = PasswordHistoryDiskSourceImpl(fakePasswordHistoryDao) + private val testUserId = "testUserId" + + @Test + fun `insertPassword calls dao insertPasswordHistory`() = runTest { + val passwordHistoryEntity = PasswordHistoryEntity( + id = 0, + userId = testUserId, + encryptedPassword = "encrypted", + generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(), + ) + + diskSource.insertPasswordHistory(passwordHistoryEntity) + + assertTrue(fakePasswordHistoryDao.storedPasswordHistories.contains(passwordHistoryEntity)) + } + + @Test + fun `getPasswordHistoriesForUser returns flow from dao`() = runTest { + val passwordHistoryEntity = PasswordHistoryEntity( + id = 0, + userId = testUserId, + encryptedPassword = "encrypted", + generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(), + ) + fakePasswordHistoryDao.insertPasswordHistory(passwordHistoryEntity) + + val result = diskSource + .getPasswordHistoriesForUser(testUserId) + .first() + + assertEquals(listOf(passwordHistoryEntity), result) + } + + @Test + fun `clearPasswordHistoriesForUser calls dao clearPasswordHistoriesForUser`() = runTest { + fakePasswordHistoryDao.storedPasswordHistories.add( + PasswordHistoryEntity( + id = 1, + userId = testUserId, + encryptedPassword = "encrypted", + generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(), + ), + ) + + diskSource.clearPasswordHistories(testUserId) + + assertTrue(fakePasswordHistoryDao.storedPasswordHistories.none { it.userId == testUserId }) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/dao/FakePasswordHistoryDao.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/dao/FakePasswordHistoryDao.kt new file mode 100644 index 0000000000..7af6d274e6 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/dao/FakePasswordHistoryDao.kt @@ -0,0 +1,36 @@ +package com.x8bit.bitwarden.data.tools.generator.datasource.disk.dao + +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map + +class FakePasswordHistoryDao : PasswordHistoryDao { + val storedPasswordHistories = mutableListOf() + + private val passwordHistoriesFlow = MutableSharedFlow>( + replay = 1, + extraBufferCapacity = Int.MAX_VALUE, + ) + + init { + passwordHistoriesFlow.tryEmit(emptyList()) + } + + override suspend fun insertPasswordHistory(passwordHistory: PasswordHistoryEntity) { + storedPasswordHistories.add(passwordHistory) + passwordHistoriesFlow.tryEmit(storedPasswordHistories.toList()) + } + + override fun getPasswordHistoriesForUserAsFlow( + userId: String, + ): Flow> { + return passwordHistoriesFlow + .map { histories -> histories.filter { it.userId == userId } } + } + + override suspend fun clearPasswordHistoriesForUser(userId: String) { + storedPasswordHistories.removeAll { it.userId == userId } + passwordHistoriesFlow.tryEmit(storedPasswordHistories.toList()) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/entity/PasswordHistoryEntityTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/entity/PasswordHistoryEntityTest.kt new file mode 100644 index 0000000000..d8e6d50c29 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/datasource/disk/entity/PasswordHistoryEntityTest.kt @@ -0,0 +1,43 @@ +package com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity + +import com.bitwarden.core.PasswordHistory +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import java.time.Instant + +class PasswordHistoryEntityTest { + + @Test + fun `toPasswordHistoryEntity should return the correct value`() { + val passwordHistory = PasswordHistory( + password = "testPassword", + lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"), + ) + val expectedEntity = PasswordHistoryEntity( + id = 0, + userId = "testId", + encryptedPassword = "testPassword", + generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(), + ) + val entity = passwordHistory.toPasswordHistoryEntity("testId") + + assertEquals(expectedEntity, entity) + } + + @Test + fun `toPasswordHistory should return the correct value`() { + val entity = PasswordHistoryEntity( + id = 1, + userId = "testId", + encryptedPassword = "testPassword", + generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(), + ) + val passwordHistory = entity.toPasswordHistory() + val expectedPasswordHistory = PasswordHistory( + password = "testPassword", + lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"), + ) + + assertEquals(expectedPasswordHistory, passwordHistory) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt index 3bea4c1c20..d32f54a1f1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt @@ -1,7 +1,10 @@ package com.x8bit.bitwarden.data.tools.generator.repository +import app.cash.turbine.test import com.bitwarden.core.PassphraseGeneratorRequest import com.bitwarden.core.PasswordGeneratorRequest +import com.bitwarden.core.PasswordHistory +import com.bitwarden.core.PasswordHistoryView import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson @@ -11,34 +14,45 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorUserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson +import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.PasswordHistoryEntity import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource +import com.x8bit.bitwarden.data.tools.generator.datasource.disk.entity.toPasswordHistoryEntity import com.x8bit.bitwarden.data.tools.generator.datasource.sdk.GeneratorSdkSource import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions -import io.mockk.Runs +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.just import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.time.Instant class GeneratorRepositoryTest { private val generatorSdkSource: GeneratorSdkSource = mockk() private val generatorDiskSource: GeneratorDiskSource = mockk() private val authDiskSource: AuthDiskSource = mockk() + private val passwordHistoryDiskSource: PasswordHistoryDiskSource = mockk() + private val vaultSdkSource: VaultSdkSource = mockk() private val repository = GeneratorRepositoryImpl( generatorSdkSource = generatorSdkSource, generatorDiskSource = generatorDiskSource, authDiskSource = authDiskSource, + passwordHistoryDiskSource = passwordHistoryDiskSource, + vaultSdkSource = vaultSdkSource, ) @BeforeEach @@ -213,13 +227,105 @@ class GeneratorRepositoryTest { coEvery { generatorDiskSource.storePasscodeGenerationOptions(userId, optionsToSave) - } just Runs + } just runs repository.savePasscodeGenerationOptions(optionsToSave) coVerify { generatorDiskSource.storePasscodeGenerationOptions(userId, optionsToSave) } } + @Test + fun `storePasswordHistory should call encrypt and insert functions`() = runTest { + val testUserId = "testUserId" + val passwordHistoryView = PasswordHistoryView( + password = "decryptedPassword", + lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"), + ) + val encryptedPasswordHistory = PasswordHistory( + password = "encryptedPassword", + lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"), + ) + val expectedPasswordHistoryEntity = encryptedPasswordHistory + .toPasswordHistoryEntity(testUserId) + + coEvery { authDiskSource.userState?.activeUserId } returns testUserId + + coEvery { vaultSdkSource.encryptPasswordHistory(passwordHistoryView) } returns + Result.success(encryptedPasswordHistory) + + coEvery { + passwordHistoryDiskSource.insertPasswordHistory(expectedPasswordHistoryEntity) + } just runs + + repository.storePasswordHistory(passwordHistoryView) + + coVerify { vaultSdkSource.encryptPasswordHistory(passwordHistoryView) } + coVerify { passwordHistoryDiskSource.insertPasswordHistory(expectedPasswordHistoryEntity) } + } + + @Test + fun `passwordHistoryStateFlow should emit correct states based on password history updates`() = + runTest { + val encryptedPasswordHistoryEntities = listOf( + PasswordHistoryEntity( + userId = USER_STATE.activeUserId, + encryptedPassword = "encryptedPassword1", + generatedDateTimeMs = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(), + ), + PasswordHistoryEntity( + userId = USER_STATE.activeUserId, + encryptedPassword = "encryptedPassword2", + generatedDateTimeMs = Instant.parse("2021-01-02T00:00:00Z").toEpochMilli(), + ), + ) + + val decryptedPasswordHistoryList = listOf( + PasswordHistoryView( + password = "password1", + lastUsedDate = Instant.parse("2021-01-01T00:00:00Z"), + ), + PasswordHistoryView( + password = "password2", + lastUsedDate = Instant.parse("2021-01-02T00:00:00Z"), + ), + ) + + coEvery { authDiskSource.userStateFlow } returns flowOf(USER_STATE) + + coEvery { + passwordHistoryDiskSource.getPasswordHistoriesForUser(USER_STATE.activeUserId) + } returns flowOf(encryptedPasswordHistoryEntities) + + coEvery { + vaultSdkSource.decryptPasswordHistoryList(any()) + } returns Result.success(decryptedPasswordHistoryList) + + val historyFlow = repository.passwordHistoryStateFlow + + historyFlow.test { + assertEquals(LocalDataState.Loading, awaitItem()) + assertEquals(LocalDataState.Loaded(decryptedPasswordHistoryList), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + + coVerify { + passwordHistoryDiskSource.getPasswordHistoriesForUser(USER_STATE.activeUserId) + } + + coVerify { vaultSdkSource.decryptPasswordHistoryList(any()) } + } + + @Test + fun `clearPasswordHistory should call clearAllPasswords function`() = runTest { + val testUserId = "testUserId" + coEvery { authDiskSource.userState?.activeUserId } returns testUserId + coEvery { passwordHistoryDiskSource.clearPasswordHistories(testUserId) } just runs + + repository.clearPasswordHistory() + + coVerify { passwordHistoryDiskSource.clearPasswordHistories(testUserId) } + } + @Test fun `savePasscodeGenerationOptions should not store options when there is no active user`() = runTest { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt index 0966d1dc62..cfb2dce0ab 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/tools/generator/repository/util/FakeGeneratorRepository.kt @@ -2,10 +2,14 @@ package com.x8bit.bitwarden.data.tools.generator.repository.util import com.bitwarden.core.PassphraseGeneratorRequest import com.bitwarden.core.PasswordGeneratorRequest +import com.bitwarden.core.PasswordHistoryView +import com.x8bit.bitwarden.data.platform.repository.model.LocalDataState import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPassphraseResult import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratedPasswordResult import com.x8bit.bitwarden.data.tools.generator.repository.model.PasscodeGenerationOptions +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * A fake implementation of [GeneratorRepository] for testing purposes. @@ -21,6 +25,12 @@ class FakeGeneratorRepository : GeneratorRepository { ) private var passcodeGenerationOptions: PasscodeGenerationOptions? = null + private val mutablePasswordHistoryStateFlow = + MutableStateFlow>>(LocalDataState.Loading) + + override val passwordHistoryStateFlow: StateFlow>> + get() = mutablePasswordHistoryStateFlow + override suspend fun generatePassword( passwordGeneratorRequest: PasswordGeneratorRequest, ): GeneratedPasswordResult { @@ -41,6 +51,16 @@ class FakeGeneratorRepository : GeneratorRepository { passcodeGenerationOptions = options } + override suspend fun storePasswordHistory(passwordHistoryView: PasswordHistoryView) { + val currentList = mutablePasswordHistoryStateFlow.value.data.orEmpty() + val updatedList = currentList + passwordHistoryView + mutablePasswordHistoryStateFlow.value = LocalDataState.Loaded(updatedList) + } + + override suspend fun clearPasswordHistory() { + mutablePasswordHistoryStateFlow.value = LocalDataState.Loaded(emptyList()) + } + /** * Sets the mock result for the generatePassword function. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt index 6f8ca9d030..977503463b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt @@ -9,10 +9,13 @@ import com.bitwarden.core.Folder import com.bitwarden.core.FolderView import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoRequest +import com.bitwarden.core.PasswordHistory +import com.bitwarden.core.PasswordHistoryView import com.bitwarden.core.Send import com.bitwarden.core.SendView import com.bitwarden.sdk.BitwardenException import com.bitwarden.sdk.ClientCrypto +import com.bitwarden.sdk.ClientPasswordHistory import com.bitwarden.sdk.ClientVault import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asSuccess @@ -27,9 +30,11 @@ import org.junit.jupiter.api.Test class VaultSdkSourceTest { private val clientVault = mockk() private val clientCrypto = mockk() + private val clientPasswordHistory = mockk() private val vaultSdkSource: VaultSdkSource = VaultSdkSourceImpl( clientVault = clientVault, clientCrypto = clientCrypto, + clientPasswordHistory = clientPasswordHistory, ) @Test @@ -404,4 +409,50 @@ class VaultSdkSourceTest { ) } } + + @Test + fun `encryptPasswordHistory should call SDK and return a Result with correct data`() = + runBlocking { + val mockPasswordHistoryView = mockk() + val expectedResult = mockk() + coEvery { + clientPasswordHistory.encrypt( + passwordHistory = mockPasswordHistoryView, + ) + } returns expectedResult + + val result = vaultSdkSource.encryptPasswordHistory( + passwordHistory = mockPasswordHistoryView, + ) + + assertEquals(expectedResult.asSuccess(), result) + coVerify { + clientPasswordHistory.encrypt( + passwordHistory = mockPasswordHistoryView, + ) + } + } + + @Test + fun `decryptPasswordHistoryList should call SDK and return a Result with correct data`() = + runBlocking { + val mockPasswordHistoryList = mockk>() + val expectedResult = mockk>() + coEvery { + clientPasswordHistory.decryptList( + list = mockPasswordHistoryList, + ) + } returns expectedResult + + val result = vaultSdkSource.decryptPasswordHistoryList( + passwordHistoryList = mockPasswordHistoryList, + ) + + assertEquals(expectedResult.asSuccess(), result) + coVerify { + clientPasswordHistory.decryptList( + list = mockPasswordHistoryList, + ) + } + } }