From e1cd813445cf454871949dd68a04eadf9c46b8fe Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Fri, 6 Jun 2025 13:51:06 -0400 Subject: [PATCH] [PM-19107] Introduce user-trusted privileged apps for Credential Manager (#4848) --- .../1.json | 38 + .../disk/PrivilegedAppDiskSource.kt | 36 + .../disk/PrivilegedAppDiskSourceImpl.kt | 51 ++ .../datasource/disk/dao/PrivilegedAppDao.kt | 48 + .../disk/database/PrivilegedAppDatabase.kt | 21 + .../disk/di/CredentialsDiskModule.kt | 50 + .../disk/entity/PrivilegedAppEntity.kt | 19 + .../di/CredentialProviderModule.kt | 15 + .../credentials/manager/OriginManagerImpl.kt | 40 +- .../model/PrivilegedAppAllowListJson.kt | 50 + .../repository/PrivilegedAppRepository.kt | 40 + .../repository/PrivilegedAppRepositoryImpl.kt | 84 ++ .../platform/util/CallingAppInfoExtensions.kt | 2 +- .../itemlisting/VaultItemListingScreen.kt | 40 +- .../itemlisting/VaultItemListingViewModel.kt | 180 +++- app/src/main/res/values/strings.xml | 3 + .../disk/PrivilegedAppDiskSourceTest.kt | 116 +++ .../credentials/manager/OriginManagerTest.kt | 55 +- .../Fido2CredentialAssertionRequestUtil.kt | 3 +- .../repository/PrivilegedAppRepositoryTest.kt | 226 +++++ .../itemlisting/VaultItemListingScreenTest.kt | 57 ++ .../VaultItemListingViewModelTest.kt | 867 +++++++++++------- 22 files changed, 1676 insertions(+), 365 deletions(-) create mode 100644 app/schemas/com.x8bit.bitwarden.data.credentials.datasource.disk.database.PrivilegedAppDatabase/1.json create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSource.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSourceImpl.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/dao/PrivilegedAppDao.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/database/PrivilegedAppDatabase.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/di/CredentialsDiskModule.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/entity/PrivilegedAppEntity.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/model/PrivilegedAppAllowListJson.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepository.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryImpl.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSourceTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryTest.kt diff --git a/app/schemas/com.x8bit.bitwarden.data.credentials.datasource.disk.database.PrivilegedAppDatabase/1.json b/app/schemas/com.x8bit.bitwarden.data.credentials.datasource.disk.database.PrivilegedAppDatabase/1.json new file mode 100644 index 0000000000..acce47e7dd --- /dev/null +++ b/app/schemas/com.x8bit.bitwarden.data.credentials.datasource.disk.database.PrivilegedAppDatabase/1.json @@ -0,0 +1,38 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "ce40856ec88770d11b7afb587c7deabc", + "entities": [ + { + "tableName": "privileged_apps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `signature` TEXT NOT NULL, PRIMARY KEY(`package_name`, `signature`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "package_name", + "signature" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ce40856ec88770d11b7afb587c7deabc')" + ] + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSource.kt new file mode 100644 index 0000000000..26447dd563 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSource.kt @@ -0,0 +1,36 @@ +package com.x8bit.bitwarden.data.credentials.datasource.disk + +import com.x8bit.bitwarden.data.credentials.datasource.disk.entity.PrivilegedAppEntity +import kotlinx.coroutines.flow.Flow + +/** + * Primary access point for disk information related to privileged apps trusted for + * Credential Manager operations. + */ +interface PrivilegedAppDiskSource { + + /** + * Flow of the user's trusted privileged apps. + */ + val userTrustedPrivilegedAppsFlow: Flow> + + /** + * Retrieves all the user's trusted privileged apps. + */ + suspend fun getAllUserTrustedPrivilegedApps(): List + + /** + * Adds a privileged app to the user's trusted list. + */ + suspend fun addTrustedPrivilegedApp(packageName: String, signature: String) + + /** + * Removes a privileged app from the user's trusted list. + */ + suspend fun removeTrustedPrivilegedApp(packageName: String, signature: String) + + /** + * Checks if a privileged app is trusted. + */ + suspend fun isPrivilegedAppTrustedByUser(packageName: String, signature: String): Boolean +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSourceImpl.kt new file mode 100644 index 0000000000..4f048b8710 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSourceImpl.kt @@ -0,0 +1,51 @@ +package com.x8bit.bitwarden.data.credentials.datasource.disk + +import com.x8bit.bitwarden.data.credentials.datasource.disk.dao.PrivilegedAppDao +import com.x8bit.bitwarden.data.credentials.datasource.disk.entity.PrivilegedAppEntity +import kotlinx.coroutines.flow.Flow + +/** + * Implementation of the [PrivilegedAppDiskSource] interface. + */ +class PrivilegedAppDiskSourceImpl( + private val privilegedAppDao: PrivilegedAppDao, +) : PrivilegedAppDiskSource { + + override val userTrustedPrivilegedAppsFlow: Flow> = + privilegedAppDao.getUserTrustedPrivilegedAppsFlow() + + override suspend fun getAllUserTrustedPrivilegedApps(): List { + return privilegedAppDao.getAllUserTrustedPrivilegedApps() + } + + override suspend fun isPrivilegedAppTrustedByUser( + packageName: String, + signature: String, + ): Boolean = privilegedAppDao + .isPrivilegedAppTrustedByUser( + packageName = packageName, + signature = signature, + ) + + override suspend fun addTrustedPrivilegedApp( + packageName: String, + signature: String, + ) { + privilegedAppDao.addTrustedPrivilegedApp( + appInfo = PrivilegedAppEntity( + packageName = packageName, + signature = signature, + ), + ) + } + + override suspend fun removeTrustedPrivilegedApp( + packageName: String, + signature: String, + ) { + privilegedAppDao.removeTrustedPrivilegedApp( + packageName = packageName, + signature = signature, + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/dao/PrivilegedAppDao.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/dao/PrivilegedAppDao.kt new file mode 100644 index 0000000000..de3d94b114 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/dao/PrivilegedAppDao.kt @@ -0,0 +1,48 @@ +package com.x8bit.bitwarden.data.credentials.datasource.disk.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.x8bit.bitwarden.data.credentials.datasource.disk.entity.PrivilegedAppEntity +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object (DAO) for privileged apps. + */ +@Dao +interface PrivilegedAppDao { + + /** + * A flow of all the trusted privileged apps. + */ + @Query("SELECT * FROM privileged_apps") + fun getUserTrustedPrivilegedAppsFlow(): Flow> + + /** + * Retrieves all the trusted privileged apps. + */ + @Query("SELECT * FROM privileged_apps") + suspend fun getAllUserTrustedPrivilegedApps(): List + + /** + * Adds a trusted privileged app. + */ + @Insert(onConflict = OnConflictStrategy.Companion.REPLACE) + suspend fun addTrustedPrivilegedApp(appInfo: PrivilegedAppEntity) + + /** + * Removes a trusted privileged app. + */ + @Query( + "DELETE FROM privileged_apps WHERE package_name = :packageName AND signature = :signature", + ) + suspend fun removeTrustedPrivilegedApp(packageName: String, signature: String) + + /** + * Checks if a privileged app is trusted by the user. + */ + @Suppress("MaxLineLength") + @Query("SELECT EXISTS(SELECT * FROM privileged_apps WHERE package_name = :packageName AND signature = :signature)") + suspend fun isPrivilegedAppTrustedByUser(packageName: String, signature: String): Boolean +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/database/PrivilegedAppDatabase.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/database/PrivilegedAppDatabase.kt new file mode 100644 index 0000000000..35c9a284d8 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/database/PrivilegedAppDatabase.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.data.credentials.datasource.disk.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.x8bit.bitwarden.data.credentials.datasource.disk.dao.PrivilegedAppDao +import com.x8bit.bitwarden.data.credentials.datasource.disk.entity.PrivilegedAppEntity + +/** + * Room database for storing privileged apps. + */ +@Database( + entities = [PrivilegedAppEntity::class], + version = 1, +) +abstract class PrivilegedAppDatabase : RoomDatabase() { + + /** + * Provides the DAO for accessing privileged apps. + */ + abstract fun privilegedAppDao(): PrivilegedAppDao +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/di/CredentialsDiskModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/di/CredentialsDiskModule.kt new file mode 100644 index 0000000000..f7fd3cbc56 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/di/CredentialsDiskModule.kt @@ -0,0 +1,50 @@ +package com.x8bit.bitwarden.data.credentials.datasource.disk.di + +import android.app.Application +import androidx.room.Room +import com.x8bit.bitwarden.data.credentials.datasource.disk.PrivilegedAppDiskSource +import com.x8bit.bitwarden.data.credentials.datasource.disk.PrivilegedAppDiskSourceImpl +import com.x8bit.bitwarden.data.credentials.datasource.disk.dao.PrivilegedAppDao +import com.x8bit.bitwarden.data.credentials.datasource.disk.database.PrivilegedAppDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Provides persistence-related dependencies in the credentials package. + */ +@Module +@InstallIn(SingletonComponent::class) +object CredentialsDiskModule { + + @Provides + @Singleton + fun providePrivilegedAppDatabase( + app: Application, + ): PrivilegedAppDatabase = + Room + .databaseBuilder( + context = app, + klass = PrivilegedAppDatabase::class.java, + name = "privileged_apps_database", + ) + .fallbackToDestructiveMigration(dropAllTables = true) + .build() + + @Provides + @Singleton + fun providePrivilegedAppDao( + database: PrivilegedAppDatabase, + ): PrivilegedAppDao = database.privilegedAppDao() + + @Provides + @Singleton + fun providePrivilegedAppDiskSource( + privilegedAppDao: PrivilegedAppDao, + ): PrivilegedAppDiskSource = + PrivilegedAppDiskSourceImpl( + privilegedAppDao = privilegedAppDao, + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/entity/PrivilegedAppEntity.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/entity/PrivilegedAppEntity.kt new file mode 100644 index 0000000000..9495507baa --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/entity/PrivilegedAppEntity.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.credentials.datasource.disk.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity + +/** + * Entity representing a trusted privileged app in the database. + */ +@Entity( + tableName = "privileged_apps", + primaryKeys = ["package_name", "signature"], +) +data class PrivilegedAppEntity( + @ColumnInfo(name = "package_name") + val packageName: String, + + @ColumnInfo(name = "signature") + val signature: String, +) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt index 41bfb715ac..7dcc2239b3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt @@ -9,12 +9,15 @@ import com.bitwarden.sdk.Fido2CredentialStore import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilder import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilderImpl +import com.x8bit.bitwarden.data.credentials.datasource.disk.PrivilegedAppDiskSource import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl import com.x8bit.bitwarden.data.credentials.manager.OriginManager import com.x8bit.bitwarden.data.credentials.manager.OriginManagerImpl import com.x8bit.bitwarden.data.credentials.processor.CredentialProviderProcessor import com.x8bit.bitwarden.data.credentials.processor.CredentialProviderProcessorImpl +import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository +import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepositoryImpl import com.x8bit.bitwarden.data.platform.manager.AssetManager import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager @@ -85,10 +88,12 @@ object CredentialProviderModule { fun provideOriginManager( assetManager: AssetManager, digitalAssetLinkService: DigitalAssetLinkService, + privilegedAppRepository: PrivilegedAppRepository, ): OriginManager = OriginManagerImpl( assetManager = assetManager, digitalAssetLinkService = digitalAssetLinkService, + privilegedAppRepository = privilegedAppRepository, ) @Provides @@ -104,4 +109,14 @@ object CredentialProviderModule { featureFlagManager = featureFlagManager, biometricsEncryptionManager = biometricsEncryptionManager, ) + + @Provides + @Singleton + fun providePrivilegedAppRepository( + privilegedAppDiskSource: PrivilegedAppDiskSource, + json: Json, + ): PrivilegedAppRepository = PrivilegedAppRepositoryImpl( + privilegedAppDiskSource = privilegedAppDiskSource, + json = json, + ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt index 628c354860..12f2afd21a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerImpl.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.credentials.manager import androidx.credentials.provider.CallingAppInfo import com.bitwarden.network.service.DigitalAssetLinkService import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult +import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository import com.x8bit.bitwarden.data.platform.manager.AssetManager import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp @@ -18,6 +19,7 @@ private const val DELEGATE_PERMISSION_HANDLE_ALL_URLS = "delegate_permission/com class OriginManagerImpl( private val assetManager: AssetManager, private val digitalAssetLinkService: DigitalAssetLinkService, + private val privilegedAppRepository: PrivilegedAppRepository, ) : OriginManager { override suspend fun validateOrigin( @@ -57,23 +59,12 @@ class OriginManagerImpl( private suspend fun validatePrivilegedAppOrigin( callingAppInfo: CallingAppInfo, - ): ValidateOriginResult { - val googleAllowListResult = - validatePrivilegedAppSignatureWithGoogleList(callingAppInfo) - return when (googleAllowListResult) { - is ValidateOriginResult.Success -> { - // Application was found and successfully validated against the Google allow list so - // we can return the result as the final validation result. - googleAllowListResult - } - - is ValidateOriginResult.Error -> { - // Check the community allow list if the Google allow list failed, and return the - // result as the final validation result. - validatePrivilegedAppSignatureWithCommunityList(callingAppInfo) - } - } - } + ): ValidateOriginResult = + validatePrivilegedAppSignatureWithGoogleList(callingAppInfo) + .takeUnless { it is ValidateOriginResult.Error } + ?: validatePrivilegedAppSignatureWithCommunityList(callingAppInfo) + .takeUnless { it is ValidateOriginResult.Error } + ?: validatePrivilegedAppSignatureWithUserTrustList(callingAppInfo) private suspend fun validatePrivilegedAppSignatureWithGoogleList( callingAppInfo: CallingAppInfo, @@ -85,11 +76,16 @@ class OriginManagerImpl( private suspend fun validatePrivilegedAppSignatureWithCommunityList( callingAppInfo: CallingAppInfo, - ): ValidateOriginResult = - validatePrivilegedAppSignatureWithAllowList( - callingAppInfo = callingAppInfo, - fileName = COMMUNITY_ALLOW_LIST_FILE_NAME, - ) + ): ValidateOriginResult = validatePrivilegedAppSignatureWithAllowList( + callingAppInfo = callingAppInfo, + fileName = COMMUNITY_ALLOW_LIST_FILE_NAME, + ) + + private suspend fun validatePrivilegedAppSignatureWithUserTrustList( + callingAppInfo: CallingAppInfo, + ): ValidateOriginResult = callingAppInfo.validatePrivilegedApp( + allowList = privilegedAppRepository.getUserTrustedAllowListJson(), + ) private suspend fun validatePrivilegedAppSignatureWithAllowList( callingAppInfo: CallingAppInfo, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/model/PrivilegedAppAllowListJson.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/model/PrivilegedAppAllowListJson.kt new file mode 100644 index 0000000000..873a30f294 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/model/PrivilegedAppAllowListJson.kt @@ -0,0 +1,50 @@ +package com.x8bit.bitwarden.data.credentials.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models the privileged application allow list JSON used in conjunction with Credential Manager + * requests. + */ +@Serializable +data class PrivilegedAppAllowListJson( + @SerialName("apps") + val apps: List, +) { + /** + * Models the privileged application JSON. + */ + @Serializable + data class PrivilegedAppJson( + @SerialName("type") + val type: String, + + @SerialName("info") + val info: InfoJson, + ) { + /** + * Models the privileged application info JSON. + */ + @Serializable + data class InfoJson( + @SerialName("package_name") + val packageName: String, + + @SerialName("signatures") + val signatures: List, + ) { + /** + * Models the privileged application signature JSON. + */ + @Serializable + data class SignatureJson( + @SerialName("build") + val build: String, + + @SerialName("cert_fingerprint_sha256") + val certFingerprintSha256: String, + ) + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepository.kt new file mode 100644 index 0000000000..bcc3808342 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepository.kt @@ -0,0 +1,40 @@ +package com.x8bit.bitwarden.data.credentials.repository + +import com.x8bit.bitwarden.data.credentials.model.PrivilegedAppAllowListJson +import kotlinx.coroutines.flow.Flow + +/** + * Repository for managing privileged apps trusted by the user. + */ +interface PrivilegedAppRepository { + + /** + * Flow of the user's trusted privileged apps. + */ + val userTrustedPrivilegedAppsFlow: Flow + + /** + * List the user's trusted privileged apps. + */ + suspend fun getAllUserTrustedPrivilegedApps(): PrivilegedAppAllowListJson + + /** + * Returns true if the given [packageName] and [signature] are trusted. + */ + suspend fun isPrivilegedAppAllowed(packageName: String, signature: String): Boolean + + /** + * Adds the given [packageName] and [signature] to the list of trusted privileged apps. + */ + suspend fun addTrustedPrivilegedApp(packageName: String, signature: String) + + /** + * Removes the given [packageName] and [signature] from the list of trusted privileged apps. + */ + suspend fun removeTrustedPrivilegedApp(packageName: String, signature: String) + + /** + * Returns the JSON representation of the user's trusted privileged apps. + */ + suspend fun getUserTrustedAllowListJson(): String +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryImpl.kt new file mode 100644 index 0000000000..c48b2e3da4 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryImpl.kt @@ -0,0 +1,84 @@ +package com.x8bit.bitwarden.data.credentials.repository + +import com.x8bit.bitwarden.data.credentials.datasource.disk.PrivilegedAppDiskSource +import com.x8bit.bitwarden.data.credentials.datasource.disk.entity.PrivilegedAppEntity +import com.x8bit.bitwarden.data.credentials.model.PrivilegedAppAllowListJson +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json + +private const val ANDROID_TYPE = "android" +private const val RELEASE_BUILD = "release" + +/** + * Primary implementation of [PrivilegedAppRepository]. + */ +class PrivilegedAppRepositoryImpl( + private val privilegedAppDiskSource: PrivilegedAppDiskSource, + private val json: Json, +) : PrivilegedAppRepository { + + override val userTrustedPrivilegedAppsFlow: Flow = + privilegedAppDiskSource.userTrustedPrivilegedAppsFlow + .map { it.toPrivilegedAppAllowListJson() } + + override suspend fun getAllUserTrustedPrivilegedApps(): PrivilegedAppAllowListJson = + privilegedAppDiskSource.getAllUserTrustedPrivilegedApps() + .toPrivilegedAppAllowListJson() + + override suspend fun isPrivilegedAppAllowed( + packageName: String, + signature: String, + ): Boolean = privilegedAppDiskSource + .isPrivilegedAppTrustedByUser( + packageName = packageName, + signature = signature, + ) + + override suspend fun addTrustedPrivilegedApp( + packageName: String, + signature: String, + ): Unit = privilegedAppDiskSource + .addTrustedPrivilegedApp( + packageName = packageName, + signature = signature, + ) + + override suspend fun removeTrustedPrivilegedApp( + packageName: String, + signature: String, + ): Unit = privilegedAppDiskSource + .removeTrustedPrivilegedApp( + packageName = packageName, + signature = signature, + ) + + override suspend fun getUserTrustedAllowListJson(): String = json + .encodeToString( + privilegedAppDiskSource + .getAllUserTrustedPrivilegedApps() + .toPrivilegedAppAllowListJson(), + ) +} + +private fun List.toPrivilegedAppAllowListJson() = + PrivilegedAppAllowListJson( + apps = map { it.toPrivilegedAppJson() }, + ) + +private fun PrivilegedAppEntity.toPrivilegedAppJson() = + PrivilegedAppAllowListJson.PrivilegedAppJson( + type = ANDROID_TYPE, + info = PrivilegedAppAllowListJson.PrivilegedAppJson.InfoJson( + packageName = packageName, + signatures = listOf( + PrivilegedAppAllowListJson + .PrivilegedAppJson + .InfoJson + .SignatureJson( + build = RELEASE_BUILD, + certFingerprintSha256 = signature, + ), + ), + ), + ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt index 0c35bf20c3..26d1416d42 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt @@ -23,7 +23,7 @@ fun CallingAppInfo.getSignatureFingerprintAsHexString(): String? { */ fun CallingAppInfo.validatePrivilegedApp(allowList: String): ValidateOriginResult { - if (!allowList.contains("\"package_name\": \"$packageName\"")) { + if (!allowList.contains("\"$packageName\"")) { return ValidateOriginResult.Error.PrivilegedAppNotAllowed } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index 7ce0c3dadc..aa04365bc4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -29,6 +29,7 @@ import com.bitwarden.ui.platform.components.appbar.model.OverflowMenuItemData import com.bitwarden.ui.platform.components.fab.BitwardenFloatingActionButton import com.bitwarden.ui.platform.components.util.rememberVectorPainter import com.bitwarden.ui.util.Text +import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.credentials.manager.CredentialProviderCompletionManager import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem @@ -40,6 +41,7 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenPinDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.model.BitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold @@ -223,7 +225,7 @@ fun VaultItemListingScreen( onDismissRequest = remember(viewModel) { { viewModel.trySendAction(VaultItemListingsAction.DismissDialogClick) } }, - onDismissFido2ErrorDialog = remember(viewModel) { + onDismissCredentialManagerErrorDialog = remember(viewModel) { { errorMessage -> viewModel.trySendAction( VaultItemListingsAction @@ -307,6 +309,13 @@ fun VaultItemListingScreen( ) } }, + onTrustPrivilegedAppClick = remember(viewModel) { + { + viewModel.trySendAction( + VaultItemListingsAction.TrustPrivilegedAppClick(it), + ) + } + }, ) val vaultItemListingHandlers = remember(viewModel) { @@ -326,7 +335,7 @@ fun VaultItemListingScreen( private fun VaultItemListingDialogs( dialogState: VaultItemListingState.DialogState?, onDismissRequest: () -> Unit, - onDismissFido2ErrorDialog: (Text) -> Unit, + onDismissCredentialManagerErrorDialog: (Text) -> Unit, onConfirmOverwriteExistingPasskey: (cipherViewId: String) -> Unit, onSubmitMasterPasswordFido2Verification: (password: String, cipherId: String) -> Unit, onRetryFido2PasswordVerification: (cipherId: String) -> Unit, @@ -336,6 +345,7 @@ private fun VaultItemListingDialogs( onRetryPinSetUpFido2Verification: (cipherId: String) -> Unit, onDismissFido2Verification: () -> Unit, onVaultItemTypeSelected: (CreateVaultItemType) -> Unit, + onTrustPrivilegedAppClick: (selectedCipherId: String?) -> Unit, ) { when (dialogState) { is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog( @@ -352,7 +362,7 @@ private fun VaultItemListingDialogs( is VaultItemListingState.DialogState.CredentialManagerOperationFail -> BitwardenBasicDialog( title = dialogState.title(), message = dialogState.message(), - onDismissRequest = { onDismissFido2ErrorDialog(dialogState.message) }, + onDismissRequest = { onDismissCredentialManagerErrorDialog(dialogState.message) }, ) is VaultItemListingState.DialogState.OverwritePasskeyConfirmationPrompt -> { @@ -434,6 +444,30 @@ private fun VaultItemListingDialogs( ) } + is VaultItemListingState.DialogState.TrustPrivilegedAddPrompt -> { + BitwardenTwoButtonDialog( + title = stringResource(R.string.an_error_has_occurred), + message = dialogState.message.invoke(), + confirmButtonText = stringResource(R.string.trust), + dismissButtonText = stringResource(R.string.cancel), + onConfirmClick = { + onTrustPrivilegedAppClick(dialogState.selectedCipherId) + }, + onDismissClick = { + onDismissCredentialManagerErrorDialog( + R.string.passkey_operation_failed_because_the_browser_is_not_trusted + .asText(), + ) + }, + onDismissRequest = { + onDismissCredentialManagerErrorDialog( + R.string.passkey_operation_failed_because_the_browser_is_not_trusted + .asText(), + ) + }, + ) + } + null -> Unit } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index d5c1323b76..28ccfd512f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting import android.os.Parcelable import androidx.annotation.DrawableRes import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.provider.CallingAppInfo import androidx.credentials.provider.CredentialEntry import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.credentials.provider.ProviderGetCredentialRequest @@ -43,6 +44,7 @@ import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest import com.x8bit.bitwarden.data.credentials.model.UserVerificationRequirement import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult +import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository import com.x8bit.bitwarden.data.credentials.util.getCreatePasskeyCredentialRequestOrNull import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager @@ -58,6 +60,7 @@ import com.x8bit.bitwarden.data.platform.manager.util.toGetCredentialsRequestOrN import com.x8bit.bitwarden.data.platform.manager.util.toTotpDataOrNull import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult @@ -119,6 +122,7 @@ class VaultItemListingViewModel @Inject constructor( private val vaultRepository: VaultRepository, private val environmentRepository: EnvironmentRepository, private val settingsRepository: SettingsRepository, + private val privilegedAppRepository: PrivilegedAppRepository, private val accessibilitySelectionManager: AccessibilitySelectionManager, private val autofillSelectionManager: AutofillSelectionManager, private val cipherMatchingManager: CipherMatchingManager, @@ -290,10 +294,15 @@ class VaultItemListingViewModel @Inject constructor( handleUserVerificationNotSupported(action) } - is VaultItemListingsAction.Internal -> handleInternalAction(action) is VaultItemListingsAction.ItemTypeToAddSelected -> { handleItemTypeToAddSelected(action) } + + is VaultItemListingsAction.TrustPrivilegedAppClick -> { + handleTrustPrivilegedAppClick(action) + } + + is VaultItemListingsAction.Internal -> handleInternalAction(action) } } @@ -636,6 +645,112 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun handleTrustPrivilegedAppClick( + action: VaultItemListingsAction.TrustPrivilegedAppClick, + ) { + clearDialogState() + state.createCredentialRequest + ?.let { trustPrivilegedAppAndWaitForCreationResult(request = it) } + ?: state.getCredentialsRequest + ?.let { trustPrivilegedAppAndGetCredentials(request = it) } + ?: state.fido2CredentialAssertionRequest + ?.let { + trustPrivilegedAppAndAuthenticateCredential( + request = it, + selectedCipherId = action.selectedCipherId, + ) + } + } + + private fun trustPrivilegedAppAndWaitForCreationResult(request: CreateCredentialRequest) { + val signature = request.callingAppInfo.getSignatureFingerprintAsHexString() + ?: run { + showCredentialManagerErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_invalid + .asText(), + ) + return + } + viewModelScope.launch { + trustPrivilegedApp( + packageName = request.callingAppInfo.packageName, + signature = signature, + ) + // Wait for the user to complete the credential creation flow. + } + } + + private fun trustPrivilegedAppAndGetCredentials(request: GetCredentialsRequest) { + val callingAppInfo = request.callingAppInfo + val signature = callingAppInfo?.getSignatureFingerprintAsHexString() + if (callingAppInfo == null || signature.isNullOrEmpty()) { + showCredentialManagerErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_invalid + .asText(), + ) + return + } + viewModelScope.launch { + trustPrivilegedApp( + packageName = callingAppInfo.packageName, + signature = signature, + ) + sendAction( + VaultItemListingsAction.Internal.GetCredentialEntriesResultReceive( + userId = request.userId, + result = bitwardenCredentialManager.getCredentialEntries( + getCredentialsRequest = request, + ), + ), + ) + } + } + + private fun trustPrivilegedAppAndAuthenticateCredential( + request: Fido2CredentialAssertionRequest, + selectedCipherId: String?, + ) { + val signature = request + .callingAppInfo + .getSignatureFingerprintAsHexString() + ?: run { + showCredentialManagerErrorDialog( + R.string.passkey_operation_failed_because_the_request_is_invalid + .asText(), + ) + return + } + val selectedCipherView = selectedCipherId + ?.let { getCipherViewOrNull(it) } + ?: run { + showCredentialManagerErrorDialog( + R.string.passkey_operation_failed_because_no_item_was_selected + .asText(), + ) + return + } + viewModelScope.launch { + trustPrivilegedApp( + packageName = request.callingAppInfo.packageName, + signature = signature, + ) + authenticateFido2Credential( + request = request.providerRequest, + cipherView = selectedCipherView, + ) + } + } + + private suspend fun trustPrivilegedApp( + packageName: String, + signature: String, + ) { + privilegedAppRepository.addTrustedPrivilegedApp( + packageName = packageName, + signature = signature, + ) + } + private fun handleAddVaultItemClick() { when (val itemListingType = state.itemListingType) { is VaultItemListingState.ItemListingType.Vault.Collection -> { @@ -930,7 +1045,11 @@ class VaultItemListingViewModel @Inject constructor( when (validateOriginResult) { is ValidateOriginResult.Error -> { - handleOriginValidationFail(validateOriginResult) + handleOriginValidationFail( + error = validateOriginResult, + callingAppInfo = request.callingAppInfo, + selectedCipherId = cipherView.id, + ) } is ValidateOriginResult.Success -> { @@ -1670,7 +1789,11 @@ class VaultItemListingViewModel @Inject constructor( ) when (validateOriginResult) { is ValidateOriginResult.Error -> { - handleOriginValidationFail(validateOriginResult) + handleOriginValidationFail( + error = validateOriginResult, + callingAppInfo = action.request.callingAppInfo, + selectedCipherId = null, + ) } is ValidateOriginResult.Success -> { @@ -1741,20 +1864,41 @@ class VaultItemListingViewModel @Inject constructor( } is ValidateOriginResult.Error -> { - handleOriginValidationFail(validateOriginResult) + handleOriginValidationFail( + error = validateOriginResult, + callingAppInfo = callingAppInfo, + selectedCipherId = null, + ) return@launch } } } } - private fun handleOriginValidationFail(error: ValidateOriginResult.Error) { + private fun handleOriginValidationFail( + error: ValidateOriginResult.Error, + callingAppInfo: CallingAppInfo, + selectedCipherId: String?, + ) { mutableStateFlow.update { it.copy( - dialogState = VaultItemListingState.DialogState.CredentialManagerOperationFail( - title = R.string.an_error_has_occurred.asText(), - message = error.messageResourceId.asText(), - ), + dialogState = when (error) { + is ValidateOriginResult.Error.PrivilegedAppNotAllowed -> { + @Suppress("MaxLineLength") + VaultItemListingState.DialogState.TrustPrivilegedAddPrompt( + message = R.string.passkey_operation_failed_because_browser_x_is_not_trusted + .asText(callingAppInfo.packageName), + selectedCipherId = selectedCipherId, + ) + } + + else -> { + VaultItemListingState.DialogState.CredentialManagerOperationFail( + title = R.string.an_error_has_occurred.asText(), + message = error.messageResourceId.asText(), + ) + } + }, ) } } @@ -2240,6 +2384,16 @@ data class VaultItemListingState( data class VaultItemTypeSelection( val excludedOptions: ImmutableList, ) : DialogState() + + /** + * Represents a dialog to prompting the user to trust a privileged app for Credential + * Manager operations. + */ + @Parcelize + data class TrustPrivilegedAddPrompt( + val message: Text, + val selectedCipherId: String?, + ) : DialogState() } /** @@ -2860,6 +3014,14 @@ sealed class VaultItemListingsAction { val itemType: CreateVaultItemType, ) : VaultItemListingsAction() + /** + * The user has chosen to trust the calling application for performing Credential Manager + * operations. + */ + data class TrustPrivilegedAppClick( + val selectedCipherId: String?, + ) : VaultItemListingsAction() + /** * Models actions that the [VaultItemListingViewModel] itself might send. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e6137e8151..7f7bbe47a6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -963,4 +963,7 @@ Do you want to switch to this account? Dynamic colors Apply dynamic colors based on your wallpaper Dynamic colors uses the system colors and may not meet all accessibility guidelines. + Passkey operation failed because browser (%1$s) is not trusted. Select \"Trust\" to add %1$s to the list of trusted applications or \"Cancel"\ to abort the operation. + Passkey operation failed because the browser is not trusted. + Trust diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSourceTest.kt new file mode 100644 index 0000000000..ab85d715c6 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/datasource/disk/PrivilegedAppDiskSourceTest.kt @@ -0,0 +1,116 @@ +package com.x8bit.bitwarden.data.credentials.datasource.disk + +import app.cash.turbine.test +import com.x8bit.bitwarden.data.credentials.datasource.disk.dao.PrivilegedAppDao +import com.x8bit.bitwarden.data.credentials.datasource.disk.entity.PrivilegedAppEntity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class PrivilegedAppDiskSourceTest { + + private val mutableUserTrustedPrivilegedAppsFlow = + MutableStateFlow>(emptyList()) + private val mockPrivilegedAppDao = mockk { + coEvery { getUserTrustedPrivilegedAppsFlow() } returns mutableUserTrustedPrivilegedAppsFlow + coEvery { + getAllUserTrustedPrivilegedApps() + } returns mutableUserTrustedPrivilegedAppsFlow.value + coEvery { isPrivilegedAppTrustedByUser(any(), any()) } returns false + coEvery { removeTrustedPrivilegedApp(any(), any()) } just runs + coEvery { addTrustedPrivilegedApp(any()) } just runs + } + private val privilegedAppDiskSource = PrivilegedAppDiskSourceImpl( + privilegedAppDao = mockPrivilegedAppDao, + ) + + @Test + fun `getAllUserTrustedPrivilegedApps should call getAllUserTrustedPrivilegedApps on the dao`() = + runTest { + privilegedAppDiskSource.getAllUserTrustedPrivilegedApps() + + coVerify { mockPrivilegedAppDao.getAllUserTrustedPrivilegedApps() } + } + + @Test + fun `isPrivilegedAppTrustedByUser should call isPrivilegedAppTrustedByUser on the dao`() = + runTest { + privilegedAppDiskSource.isPrivilegedAppTrustedByUser( + packageName = "mockPackageName", + signature = "mockSignature", + ) + + coVerify { + mockPrivilegedAppDao.isPrivilegedAppTrustedByUser( + "mockPackageName", + "mockSignature", + ) + } + } + + @Test + fun `addTrustedPrivilegedApp should call addTrustedPrivilegedApp on the dao`() = runTest { + privilegedAppDiskSource.addTrustedPrivilegedApp( + packageName = "mockPackageName", + signature = "mockSignature", + ) + + coVerify { + mockPrivilegedAppDao.addTrustedPrivilegedApp( + PrivilegedAppEntity( + packageName = "mockPackageName", + signature = "mockSignature", + ), + ) + } + } + + @Test + fun `removeTrustedPrivilegedApp should call removeTrustedPrivilegedApp on the dao`() = runTest { + privilegedAppDiskSource.removeTrustedPrivilegedApp( + packageName = "mockPackageName", + signature = "mockSignature", + ) + + coVerify { + mockPrivilegedAppDao.removeTrustedPrivilegedApp( + "mockPackageName", + "mockSignature", + ) + } + } + + @Test + fun `userTrustedPrivilegedAppsFlow should emit updates from the dao`() = runTest { + privilegedAppDiskSource.userTrustedPrivilegedAppsFlow.test { + // Verify the initial state is empty + assertEquals(emptyList(), awaitItem()) + + mutableUserTrustedPrivilegedAppsFlow.emit( + listOf( + PrivilegedAppEntity( + packageName = "mockPackageName", + signature = "mockSignature", + ), + ), + ) + + // Verify the updated state is correct + assertEquals( + listOf( + PrivilegedAppEntity( + packageName = "mockPackageName", + signature = "mockSignature", + ), + ), + awaitItem(), + ) + } + } +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt index 08d4b6e6b2..f49b07dda4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/manager/OriginManagerTest.kt @@ -8,6 +8,7 @@ import com.bitwarden.core.data.util.asSuccess import com.bitwarden.network.model.DigitalAssetLinkCheckResponseJson import com.bitwarden.network.service.DigitalAssetLinkService import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult +import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository import com.x8bit.bitwarden.data.platform.manager.AssetManager import io.mockk.coEvery import io.mockk.coVerify @@ -40,6 +41,7 @@ class OriginManagerTest { every { hasMultipleSigners() } returns false } } + private val mockPrivilegedAppRepository = mockk() private val mockMessageDigest = mockk { every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray() } @@ -47,6 +49,7 @@ class OriginManagerTest { private val fido2OriginManager = OriginManagerImpl( assetManager = mockAssetManager, digitalAssetLinkService = mockDigitalAssetLinkService, + privilegedAppRepository = mockPrivilegedAppRepository, ) @BeforeEach @@ -112,7 +115,7 @@ class OriginManagerTest { @Suppress("MaxLineLength") @Test - fun `validateOrigin should return ApplicationNotFound when calling app is Privileged but not in either allow list`() = + fun `validateOrigin should return Success when calling app is Privileged and is in the User Trust list`() = runTest { coEvery { mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) @@ -120,6 +123,37 @@ class OriginManagerTest { coEvery { mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) } returns FAIL_ALLOW_LIST.asSuccess() + coEvery { + mockPrivilegedAppRepository.getUserTrustedAllowListJson() + } returns DEFAULT_ALLOW_LIST + + val result = fido2OriginManager.validateOrigin( + callingAppInfo = mockPrivilegedAppInfo, + ) + coVerify(exactly = 1) { + mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) + mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) + mockPrivilegedAppRepository.getUserTrustedAllowListJson() + } + assertEquals( + ValidateOriginResult.Success(DEFAULT_ORIGIN), + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return ApplicationNotFound when calling app is Privileged but not present in an allow list`() = + runTest { + coEvery { + mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) + } returns FAIL_ALLOW_LIST.asSuccess() + coEvery { + mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) + } returns FAIL_ALLOW_LIST.asSuccess() + coEvery { + mockPrivilegedAppRepository.getUserTrustedAllowListJson() + } returns FAIL_ALLOW_LIST val result = fido2OriginManager.validateOrigin( callingAppInfo = mockPrivilegedAppInfo, @@ -194,25 +228,6 @@ class OriginManagerTest { fido2OriginManager.validateOrigin(callingAppInfo = mockNonPrivilegedAppInfo), ) } - - @Suppress("MaxLineLength") - @Test - fun `validateOrigin should return Unknown error when calling app is Privileged and allow list file read fails`() = - runTest { - coEvery { - mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME) - } returns IllegalStateException().asFailure() - coEvery { - mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME) - } returns IllegalStateException().asFailure() - - assertEquals( - ValidateOriginResult.Error.Unknown, - fido2OriginManager.validateOrigin( - callingAppInfo = mockPrivilegedAppInfo, - ), - ) - } } private const val DEFAULT_PACKAGE_NAME = "com.x8bit.bitwarden" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/model/Fido2CredentialAssertionRequestUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/model/Fido2CredentialAssertionRequestUtil.kt index 8f9005cac7..f4b2568610 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/model/Fido2CredentialAssertionRequestUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/model/Fido2CredentialAssertionRequestUtil.kt @@ -5,10 +5,11 @@ import androidx.core.os.bundleOf fun createMockFido2CredentialAssertionRequest( number: Int = 1, userId: String = "mockUserId-$number", + cipherId: String = "mockCipherId-$number", ): Fido2CredentialAssertionRequest = Fido2CredentialAssertionRequest( userId = userId, - cipherId = "mockCipherId-$number", + cipherId = cipherId, credentialId = "mockCredentialId-$number", isUserPreVerified = false, requestData = bundleOf(), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryTest.kt new file mode 100644 index 0000000000..5334f05696 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/credentials/repository/PrivilegedAppRepositoryTest.kt @@ -0,0 +1,226 @@ +package com.x8bit.bitwarden.data.credentials.repository + +import app.cash.turbine.test +import com.x8bit.bitwarden.data.credentials.datasource.disk.PrivilegedAppDiskSource +import com.x8bit.bitwarden.data.credentials.datasource.disk.entity.PrivilegedAppEntity +import com.x8bit.bitwarden.data.credentials.model.PrivilegedAppAllowListJson +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PrivilegedAppRepositoryTest { + + private val mutableUserTrustedPrivilegedAppsFlow = + MutableStateFlow>(emptyList()) + private val mockPrivilegedAppDiskSource = mockk { + every { userTrustedPrivilegedAppsFlow } returns mutableUserTrustedPrivilegedAppsFlow + coEvery { + getAllUserTrustedPrivilegedApps() + } returns mutableUserTrustedPrivilegedAppsFlow.value + coEvery { isPrivilegedAppTrustedByUser(any(), any()) } returns false + coEvery { removeTrustedPrivilegedApp(any(), any()) } just runs + coEvery { addTrustedPrivilegedApp(any(), any()) } just runs + } + private val mockJson = mockk() + private val repository = PrivilegedAppRepositoryImpl( + privilegedAppDiskSource = mockPrivilegedAppDiskSource, + json = mockJson, + ) + + @Test + fun `getAllUserTrustedPrivilegedApps should return empty list when disk source is empty`() = + runTest { + coEvery { + mockPrivilegedAppDiskSource.getAllUserTrustedPrivilegedApps() + } returns emptyList() + + val result = repository.getAllUserTrustedPrivilegedApps() + + assertTrue(result.apps.isEmpty()) + } + + @Test + fun `getAllUserTrustedPrivilegedApps should return correct data when disk source has data`() = + runTest { + val diskApps = listOf( + createMockPrivilegedAppEntity(number = 1), + createMockPrivilegedAppEntity(number = 2), + ) + coEvery { + mockPrivilegedAppDiskSource.getAllUserTrustedPrivilegedApps() + } returns diskApps + + val result = repository.getAllUserTrustedPrivilegedApps() + + assertEquals( + PrivilegedAppAllowListJson( + apps = listOf( + createMockPrivilegedAppJson(number = 1), + createMockPrivilegedAppJson(number = 2), + ), + ), + result, + ) + } + + @Test + fun `userTrustedPrivilegedAppsFlow should emit updates from disk source`() = runTest { + repository.userTrustedPrivilegedAppsFlow.test { + // Verify the initial state is empty + assertEquals(PrivilegedAppAllowListJson(apps = emptyList()), awaitItem()) + + mutableUserTrustedPrivilegedAppsFlow.emit( + listOf(createMockPrivilegedAppEntity(number = 1)), + ) + + // Verify the updated state is correct + assertEquals( + PrivilegedAppAllowListJson(apps = listOf(createMockPrivilegedAppJson(number = 1))), + awaitItem(), + ) + } + } + + @Test + fun `removeTrustedPrivilegedApp should call the disk source`() = runTest { + repository.removeTrustedPrivilegedApp( + packageName = "mockPackageName", + signature = "mockSignature", + ) + + coVerify { + mockPrivilegedAppDiskSource.removeTrustedPrivilegedApp( + "mockPackageName", + "mockSignature", + ) + } + } + + @Test + fun `isPrivilegedAppAllowed should call the disk source`() = runTest { + repository.isPrivilegedAppAllowed( + packageName = "mockPackageName", + signature = "mockSignature", + ) + + coVerify { + mockPrivilegedAppDiskSource.isPrivilegedAppTrustedByUser( + "mockPackageName", + "mockSignature", + ) + } + } + + @Test + fun `addTrustedPrivilegedApp should call the disk source`() = runTest { + repository.addTrustedPrivilegedApp( + packageName = "mockPackageName", + signature = "mockSignature", + ) + + coVerify { + mockPrivilegedAppDiskSource.addTrustedPrivilegedApp( + "mockPackageName", + "mockSignature", + ) + } + } + + @Test + fun `getUserTrustedAllowListJson should return correct JSON string with empty list`() = + runTest { + every { + mockJson.encodeToString(PrivilegedAppAllowListJson(apps = emptyList())) + } returns """{"apps":[]}""" + + val result = repository.getUserTrustedAllowListJson() + assertEquals("""{"apps":[]}""", result) + } + + @Test + fun `getUserTrustedAllowListJson should return correct JSON string with populated list`() = + runTest { + val diskApps = listOf( + createMockPrivilegedAppEntity(number = 1), + createMockPrivilegedAppEntity(number = 2), + ) + coEvery { + mockPrivilegedAppDiskSource.getAllUserTrustedPrivilegedApps() + } returns diskApps + + val expectedPrivilegedAppAllowListJson = PrivilegedAppAllowListJson( + apps = listOf( + createMockPrivilegedAppJson(number = 1), + createMockPrivilegedAppJson(number = 2), + ), + ) + + every { + mockJson.encodeToString(expectedPrivilegedAppAllowListJson) + } returns ALLOW_LIST_JSON + + assertEquals( + ALLOW_LIST_JSON, + repository.getUserTrustedAllowListJson(), + ) + } +} + +private val ALLOW_LIST_JSON = """ +{ + "apps": [ + { + "type": "android", + "info": { + "packageName": "mockPackageName-1", + "signatures": [ + { + "build": "release", + "certFingerprintSha256": "mockSignature-1" + } + ] + } + }, + { + "type": "android", + "info": { + "packageName": "mockPackageName-2", + "signatures": [ + { + "build": "release", + "certFingerprintSha256": "mockSignature-2" + } + ] + } + } + ] +} +""" + .trimIndent() + +private fun createMockPrivilegedAppEntity(number: Int) = PrivilegedAppEntity( + packageName = "mockPackageName-$number", + signature = "mockSignature-$number", +) + +private fun createMockPrivilegedAppJson(number: Int) = PrivilegedAppAllowListJson.PrivilegedAppJson( + type = "android", + info = PrivilegedAppAllowListJson.PrivilegedAppJson.InfoJson( + packageName = "mockPackageName-$number", + signatures = listOf( + PrivilegedAppAllowListJson.PrivilegedAppJson.InfoJson.SignatureJson( + build = "release", + certFingerprintSha256 = "mockSignature-$number", + ), + ), + ), +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 90fee9b4d3..8a3b52dba3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -2264,6 +2264,63 @@ class VaultItemListingScreenTest : BitwardenComposeTest() { ) } } + + @Suppress("MaxLineLength") + @Test + fun `when Trust is selected in TrustPrivilegedAppPrompt dialog TrustPrivilegedApp action is sent`() { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.TrustPrivilegedAddPrompt( + message = "message".asText(), + selectedCipherId = null, + ), + ) + } + + composeTestRule + .onNode(isDialog()) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Trust") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(VaultItemListingsAction.TrustPrivilegedAppClick(null)) + } + } + + @Suppress("MaxLineLength") + @Test + fun `when Cancel is selected in TrustPrivilegedAppPrompt dialog DismissCredentialManagerErrorDialogClick action is sent`() { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.TrustPrivilegedAddPrompt( + message = "message".asText(), + selectedCipherId = null, + ), + ) + } + + composeTestRule + .onNode(isDialog()) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction( + VaultItemListingsAction.DismissCredentialManagerErrorDialogClick( + message = R.string.passkey_operation_failed_because_the_browser_is_not_trusted + .asText(), + ), + ) + } + } } private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index c764353028..586a1969c9 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -7,6 +7,8 @@ import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.CredentialEntry import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.credentials.provider.ProviderGetCredentialRequest import androidx.credentials.provider.PublicKeyCredentialEntry @@ -53,6 +55,7 @@ import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult import com.x8bit.bitwarden.data.credentials.model.createMockCreateCredentialRequest import com.x8bit.bitwarden.data.credentials.model.createMockFido2CredentialAssertionRequest import com.x8bit.bitwarden.data.credentials.model.createMockGetCredentialsRequest +import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl @@ -65,9 +68,11 @@ import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2Credential import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2CredentialList import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -197,10 +202,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { authenticationAttempts } returns 0 every { authenticationAttempts = any() } just runs every { hasAuthenticationAttemptsRemaining() } returns true - every { getPasskeyAttestationOptionsOrNull(any()) } returns mockk(relaxed = true) every { getUserVerificationRequirement(any()) } returns UserVerificationRequirement.PREFERRED + coEvery { getCredentialEntries(any()) } returns emptyList().asSuccess() } private val originManager: OriginManager = mockk { coEvery { validateOrigin(any()) } returns ValidateOriginResult.Success(null) @@ -213,39 +218,46 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { private val networkConnectionManager: NetworkConnectionManager = mockk { every { isNetworkConnected } returns true } + private val privilegedAppRepository = mockk { + coEvery { addTrustedPrivilegedApp(any(), any()) } just runs + } private val initialState = createVaultItemListingState() private val initialSavedStateHandle get() = createSavedStateHandleWithVaultItemListingType( vaultItemListingType = VaultItemListingType.Login, ) - private val mockProviderGetCredentialRequest = - mockk(relaxed = true) { - every { - credentialOptions - } returns listOf( - mockk(relaxed = true), - ) - } - private val mockBeginGetPublicKeyCredentialOption = - mockk(relaxed = true) - private val mockBeginGetCredentialRequest = mockk(relaxed = true) { - every { - beginGetCredentialOptions - } returns listOf( - mockBeginGetPublicKeyCredentialOption, - ) + private val mockCallingAppInfo = mockk { + every { packageName } returns "mockPackageName" + every { isOriginPopulated() } returns false + } + private val mockProviderGetCredentialRequest = mockk { + every { credentialOptions } returns listOf(mockk()) + every { callingAppInfo } returns mockCallingAppInfo + } + private val mockBeginGetPublicKeyCredentialOption = mockk() + private val mockBeginGetCredentialRequest = mockk { + every { beginGetCredentialOptions } returns listOf(mockBeginGetPublicKeyCredentialOption) + every { callingAppInfo } returns mockCallingAppInfo + } + val mockProviderCreateCredentialRequest = mockk { + every { callingRequest } returns mockk(relaxed = true) + every { callingAppInfo } returns mockCallingAppInfo } @BeforeEach fun setUp() { - mockkStatic(SavedStateHandle::toVaultItemListingArgs) + mockkStatic( + SavedStateHandle::toVaultItemListingArgs, + ) mockkObject( ProviderCreateCredentialRequest.Companion, ProviderGetCredentialRequest.Companion, BeginGetCredentialRequest.Companion, ) - every { ProviderCreateCredentialRequest.fromBundle(any()) } returns mockk(relaxed = true) + every { + ProviderCreateCredentialRequest.fromBundle(any()) + } returns mockProviderCreateCredentialRequest every { ProviderGetCredentialRequest.fromBundle(any()) } returns mockProviderGetCredentialRequest @@ -256,7 +268,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @AfterEach fun tearDown() { - unmockkStatic(SavedStateHandle::toVaultItemListingArgs) + unmockkStatic( + SavedStateHandle::toVaultItemListingArgs, + CallingAppInfo::getSignatureFingerprintAsHexString, + ) unmockkObject( ProviderCreateCredentialRequest.Companion, ProviderGetCredentialRequest.Companion, @@ -277,26 +292,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Test fun `initial dialog state should be correct when CreateCredentialRequest is present`() = runTest { - val createCredentialRequest = CreateCredentialRequest( - userId = "mockUserId", - isUserPreVerified = false, - requestData = bundleOf(), - ) + val createCredentialRequest = createMockCreateCredentialRequest(number = 1) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( createCredentialRequest = createCredentialRequest, ) - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - every { callingAppInfo } returns mockk(relaxed = true) - } - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns createMockPasskeyAttestationOptions(number = 1) coEvery { originManager.validateOrigin(any()) } returns ValidateOriginResult.Success(null) @@ -653,19 +653,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { sendViewList = emptyList(), ), ) - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - } - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns createMockPasskeyAttestationOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.REQUIRED, - ) coEvery { bitwardenCredentialManager.registerFido2Credential( userId = any(), @@ -712,24 +699,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { sendViewList = emptyList(), ), ) - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - } every { bitwardenCredentialManager.getUserVerificationRequirement( request = any(), ) } returns UserVerificationRequirement.REQUIRED - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns createMockPasskeyAttestationOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.REQUIRED, - ) coEvery { bitwardenCredentialManager.registerFido2Credential( userId = any(), @@ -783,13 +757,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { sendViewList = emptyList(), ), ) - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - } every { bitwardenCredentialManager.getUserVerificationRequirement( request = any(), @@ -832,65 +799,53 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `ItemClick for vault item during FIDO 2 registration should skip user verification when user is verified`() { - setupMockUri() - val cipherView = createMockCipherView(number = 1) - val mockFido2CredentialRequest = createMockCreateCredentialRequest(number = 1) - specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.ProviderCreateCredential( - createCredentialRequest = mockFido2CredentialRequest, - ) - mutableVaultDataStateFlow.value = DataState.Loaded( - data = VaultData( - cipherViewList = listOf(cipherView), - folderViewList = emptyList(), - collectionViewList = emptyList(), - sendViewList = emptyList(), - ), - ) - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - } - every { bitwardenCredentialManager.isUserVerified } returns true - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns createMockPasskeyAttestationOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.REQUIRED, - ) - coEvery { - bitwardenCredentialManager.registerFido2Credential( - userId = any(), - callingAppInfo = any(), - createPublicKeyCredentialRequest = any(), - selectedCipherView = any(), - ) - } returns Fido2RegisterCredentialResult.Success("mockResponse") - - val viewModel = createVaultItemListingViewModel() - viewModel.trySendAction( - VaultItemListingsAction.ItemClick( - id = cipherView.id.orEmpty(), - type = VaultItemListingState.DisplayItem.ItemType.Vault( - type = CipherType.LOGIN, + fun `ItemClick for vault item during FIDO 2 registration should skip user verification when user is verified`() = + runTest { + setupMockUri() + val cipherView = createMockCipherView(number = 1) + val mockFido2CredentialRequest = createMockCreateCredentialRequest(number = 1) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.ProviderCreateCredential( + createCredentialRequest = mockFido2CredentialRequest, + ) + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView), + folderViewList = emptyList(), + collectionViewList = emptyList(), + sendViewList = emptyList(), ), - ), - ) - - coVerify { bitwardenCredentialManager.isUserVerified } - coVerify(exactly = 1) { - bitwardenCredentialManager.registerFido2Credential( - userId = DEFAULT_USER_STATE.activeUserId, - createPublicKeyCredentialRequest = any(), - selectedCipherView = cipherView, - callingAppInfo = any(), ) + every { bitwardenCredentialManager.isUserVerified } returns true + coEvery { + bitwardenCredentialManager.registerFido2Credential( + userId = any(), + callingAppInfo = any(), + createPublicKeyCredentialRequest = any(), + selectedCipherView = any(), + ) + } returns Fido2RegisterCredentialResult.Success("mockResponse") + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.ItemClick( + id = cipherView.id.orEmpty(), + type = VaultItemListingState.DisplayItem.ItemType.Vault( + type = CipherType.LOGIN, + ), + ), + ) + + coVerify { bitwardenCredentialManager.isUserVerified } + coVerify(exactly = 1) { + bitwardenCredentialManager.registerFido2Credential( + userId = DEFAULT_USER_STATE.activeUserId, + createPublicKeyCredentialRequest = any(), + selectedCipherView = cipherView, + callingAppInfo = any(), + ) + } } - } @Test fun `ItemClick for vault item should emit NavigateToVaultItem`() = runTest { @@ -1942,20 +1897,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { number = 2, fido2Credentials = createMockSdkFido2CredentialList(number = 1), ) - - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) { - every { origin } returns "mockOrigin" - } - every { callingAppInfo } returns mockk(relaxed = true) - } - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns createMockPasskeyAttestationOptions(number = 1) coEvery { vaultRepository.getDecryptedFido2CredentialAutofillViews( cipherViewList = listOf(cipherView1, cipherView2), @@ -2664,27 +2605,15 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } - //region FIDO2 process handling + //region CredentialManager request handling @Test - fun `Fido2CreateCredentialRequest should be evaluated before observing vault data`() { - val providerCreateCredentialRequest = CreateCredentialRequest( - userId = "mockUserId", - isUserPreVerified = false, - requestData = bundleOf(), - ) + fun `CreateCredentialRequest should be evaluated before observing vault data`() { specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.ProviderCreateCredential(providerCreateCredentialRequest) - - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { callingRequest } returns mockk(relaxed = true) - } - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns mockk(relaxed = true) + SpecialCircumstance.ProviderCreateCredential( + createMockCreateCredentialRequest(number = 1), + ) coEvery { - originManager.validateOrigin(callingAppInfo = any()) + originManager.validateOrigin(mockCallingAppInfo) } returns ValidateOriginResult.Success("mockOrigin") createVaultItemListingViewModel() @@ -2696,28 +2625,13 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `Fido2ValidateOriginResult should update dialog state on Unknown error`() = runTest { - val mockFido2CreateRequest = CreateCredentialRequest( - userId = "mockUserId", - isUserPreVerified = false, - requestData = bundleOf(), - ) - + fun `ValidateOriginResult should update dialog state on Unknown error`() = runTest { specialCircumstanceManager.specialCircumstance = - SpecialCircumstance.ProviderCreateCredential(mockFido2CreateRequest) - - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - } - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns mockk(relaxed = true) + SpecialCircumstance.ProviderCreateCredential( + createMockCreateCredentialRequest(number = 1), + ) coEvery { - originManager.validateOrigin(any()) + originManager.validateOrigin(mockCallingAppInfo) } returns ValidateOriginResult.Error.Unknown val viewModel = createVaultItemListingViewModel() @@ -2733,39 +2647,23 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `Fido2ValidateOriginResult should update dialog state on PrivilegedAppNotAllowed error`() = + fun `ValidateOriginResult should update dialog state on PrivilegedAppNotAllowed error`() = runTest { - val providerCreateCredentialRequest = CreateCredentialRequest( - userId = "mockUserId", - isUserPreVerified = false, - requestData = bundleOf(), - ) - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( - createCredentialRequest = providerCreateCredentialRequest, + createCredentialRequest = createMockCreateCredentialRequest(number = 1), ) - - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - } - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns mockk(relaxed = true) coEvery { - originManager.validateOrigin(any()) + originManager.validateOrigin(mockCallingAppInfo) } returns ValidateOriginResult.Error.PrivilegedAppNotAllowed val viewModel = createVaultItemListingViewModel() assertEquals( - VaultItemListingState.DialogState.CredentialManagerOperationFail( - R.string.an_error_has_occurred.asText(), - R.string.passkey_operation_failed_because_browser_is_not_privileged.asText(), + VaultItemListingState.DialogState.TrustPrivilegedAddPrompt( + message = R.string.passkey_operation_failed_because_browser_x_is_not_trusted + .asText("mockPackageName"), + selectedCipherId = null, ), viewModel.stateFlow.value.dialogState, ) @@ -2773,31 +2671,14 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `Fido2ValidateOriginResult should update dialog state on PrivilegedAppSignatureNotFound error`() = + fun `ValidateOriginResult should update dialog state on PrivilegedAppSignatureNotFound error`() = runTest { - val providerCreateCredentialRequest = CreateCredentialRequest( - userId = "mockUserId", - isUserPreVerified = false, - requestData = bundleOf(), - ) - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( - createCredentialRequest = providerCreateCredentialRequest, + createCredentialRequest = createMockCreateCredentialRequest(number = 1), ) - - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - } - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns mockk(relaxed = true) coEvery { - originManager.validateOrigin(any()) + originManager.validateOrigin(mockCallingAppInfo) } returns ValidateOriginResult.Error.PrivilegedAppSignatureNotFound val viewModel = createVaultItemListingViewModel() @@ -2813,31 +2694,14 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `Fido2ValidateOriginResult should update dialog state on PasskeyNotSupportedForApp error`() = + fun `ValidateOriginResult should update dialog state on PasskeyNotSupportedForApp error`() = runTest { - val providerCreateCredentialRequest = CreateCredentialRequest( - userId = "mockUserId", - isUserPreVerified = false, - requestData = bundleOf(), - ) - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( - createCredentialRequest = providerCreateCredentialRequest, + createCredentialRequest = createMockCreateCredentialRequest(number = 1), ) - - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - } - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns mockk(relaxed = true) coEvery { - originManager.validateOrigin(any()) + originManager.validateOrigin(mockCallingAppInfo) } returns ValidateOriginResult.Error.PasskeyNotSupportedForApp val viewModel = createVaultItemListingViewModel() @@ -2853,31 +2717,14 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `Fido2ValidateOriginResult should update dialog state on AssetLinkNotFound error`() = + fun `ValidateOriginResult should update dialog state on AssetLinkNotFound error`() = runTest { - val providerCreateCredentialRequest = CreateCredentialRequest( - userId = "mockUserId", - isUserPreVerified = false, - requestData = bundleOf(), - ) - specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( - createCredentialRequest = providerCreateCredentialRequest, + createCredentialRequest = createMockCreateCredentialRequest(number = 1), ) - - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - } - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns mockk(relaxed = true) coEvery { - originManager.validateOrigin(any()) + originManager.validateOrigin(mockCallingAppInfo) } returns ValidateOriginResult.Error.AssetLinkNotFound val viewModel = createVaultItemListingViewModel() @@ -2955,7 +2802,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `DismissFido2ErrorDialogClick should clear the dialog state then complete FIDO 2 registration based on state`() = + fun `DismissCredentialManagerErrorDialogClick should clear the dialog state then complete FIDO 2 registration based on state`() = runTest { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( @@ -2982,7 +2829,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `DismissFido2ErrorDialogClick should clear dialog state then complete FIDO 2 assertion with error when assertion request is not null`() = + fun `DismissCredentialManagerErrorDialogClick should clear dialog state then complete FIDO 2 assertion with error when assertion request is not null`() = runTest { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( createMockFido2CredentialAssertionRequest(number = 1), @@ -3016,7 +2863,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `DismissFido2ErrorDialogClick should show general error dialog when no FIDO 2 request is present`() = + fun `DismissCredentialManagerErrorDialogClick should show general error dialog when no FIDO 2 request is present`() = runTest { specialCircumstanceManager.specialCircumstance = null val viewModel = createVaultItemListingViewModel() @@ -3034,7 +2881,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `Fido2GetCredentialsRequest should validate request and emit CompleteFido2GetCredentialsRequest event`() = + fun `GetCredentialsRequest should validate request and emit CompleteProviderGetCredentialsRequest event`() = runTest { setupMockUri() val mockGetCredentialsRequest = createMockGetCredentialsRequest(number = 1) @@ -3137,7 +2984,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `Fido2GetCredentialsRequest should display error dialog when callingApp cannot be verified`() = + fun `GetCredentialsRequest should display error dialog when callingApp cannot be verified`() = runTest { setupMockUri() val mockGetCredentialsRequest = createMockGetCredentialsRequest(number = 1) @@ -3185,7 +3032,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `Fido2GetCredentialsRequest should display error dialog when origin validation fails`() = + fun `GetCredentialsRequest should display error dialog when origin validation fails`() = runTest { setupMockUri() val mockGetCredentialsRequest = createMockGetCredentialsRequest(number = 1) @@ -3760,7 +3607,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationLockout should display Fido2ErrorDialog and set isUserVerified to false`() { + fun `UserVerificationLockout should display CredentialManagerOperationFail and set isUserVerified to false`() { val viewModel = createVaultItemListingViewModel() viewModel.trySendAction(VaultItemListingsAction.UserVerificationLockOut) @@ -3776,7 +3623,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationCancelled should clear dialog state, set isUserVerified to false, and emit CompleteFido2Create with cancelled result`() = + fun `UserVerificationCancelled should clear dialog state, set isUserVerified to false, and emit CompleteFido2Registration with cancelled result`() = runTest { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( @@ -3819,8 +3666,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `UserVerificationFail should display Fido2ErrorDialog and set isUserVerified to false`() { + fun `UserVerificationFail should display CredentialManagerOperationFail and set isUserVerified to false`() { val viewModel = createVaultItemListingViewModel() viewModel.trySendAction(VaultItemListingsAction.UserVerificationFail) @@ -3837,7 +3685,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationSuccess should display Fido2ErrorDialog when SpecialCircumstance is null`() = + fun `UserVerificationSuccess should display CredentialManagerOperationFail when SpecialCircumstance is null`() = runTest { specialCircumstanceManager.specialCircumstance = null coEvery { @@ -3870,7 +3718,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationSuccess should display Fido2ErrorDialog when SpecialCircumstance is invalid`() = + fun `UserVerificationSuccess should display CredentialManagerOperationFail when SpecialCircumstance is invalid`() = runTest { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.AutofillSave( @@ -3910,7 +3758,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationSuccess should display Fido2ErrorDialog when activeUserId is null`() { + fun `UserVerificationSuccess should display CredentialManagerOperationFail when activeUserId is null`() { every { authRepository.activeUserId } returns null specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( @@ -3940,18 +3788,10 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Test fun `UserVerificationSuccess should set isUserVerified to true, and register FIDO 2 credential when verification result is received`() = runTest { - val mockRequest = createMockCreateCredentialRequest(number = 1) specialCircumstanceManager.specialCircumstance = SpecialCircumstance.ProviderCreateCredential( - createCredentialRequest = mockRequest, + createCredentialRequest = createMockCreateCredentialRequest(number = 1), ) - every { - ProviderCreateCredentialRequest.fromBundle(any()) - } returns mockk(relaxed = true) { - every { - callingRequest - } returns mockk(relaxed = true) - } coEvery { bitwardenCredentialManager.registerFido2Credential( userId = any(), @@ -4043,8 +3883,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `UserVerificationNotSupported should display Fido2CreationFail when no cipher id found`() { + fun `UserVerificationNotSupported should display CredentialManagerOperationFail when no cipher id found`() { val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( @@ -4066,7 +3907,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationNotSupported should display Fido2CreationFail when no active account found`() { + fun `UserVerificationNotSupported should display CredentialManagerOperationFail when no active account found`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" mutableUserStateFlow.value = null @@ -4088,8 +3929,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") @Test - fun `UserVerificationNotSupported should display Fido2PinPrompt when user has pin`() { + fun `UserVerificationNotSupported should display UserVerificationPinPrompt when user has pin`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( @@ -4118,7 +3960,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UserVerificationNotSupported should display Fido2MasterPasswordPrompt when user has password but no pin`() { + fun `UserVerificationNotSupported should display UserVerificationMasterPasswordPrompt when user has password but no pin`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" @@ -4174,7 +4016,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `MasterPasswordFido2VerificationSubmit should display Fido2ErrorDialog when password verification fails`() { + fun `MasterPasswordUserVerificationSubmit should display CredentialManagerOperationFail when password verification fails`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" val password = "password" @@ -4204,7 +4046,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `MasterPasswordFido2VerificationSubmit should display Fido2MasterPasswordError when user has retries remaining`() { + fun `MasterPasswordUserVerificationSubmit should display Fido2MasterPasswordError when user has retries remaining`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" val password = "password" @@ -4234,7 +4076,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `MasterPasswordFido2VerificationSubmit should display Fido2ErrorDialog when user has no retries remaining`() { + fun `MasterPasswordUserVerificationSubmit should display CredentialManagerOperationFail when user has no retries remaining`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" val password = "password" @@ -4266,7 +4108,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `MasterPasswordFido2VerificationSubmit should display Fido2ErrorDialog when cipher not found`() { + fun `MasterPasswordUserVerificationSubmit should display CredentialManagerOperationFail when cipher not found`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" val password = "password" @@ -4296,7 +4138,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `MasterPasswordFido2VerificationSubmit should register credential when password authenticated successfully`() = + fun `MasterPasswordUserVerificationSubmit should register credential when password authenticated successfully`() = runTest { val viewModel = createVaultItemListingViewModel() val cipherView = createMockCipherView(number = 1) @@ -4325,8 +4167,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `RetryFido2PasswordVerificationClick should display Fido2MasterPasswordPrompt`() { + fun `RetryUserVerificationPasswordVerificationClick should display UserVerificationMasterPasswordPrompt`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" @@ -4346,7 +4189,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `PinFido2VerificationSubmit should display Fido2ErrorDialog when pin verification fails`() { + fun `PinUserVerificationSubmit should display CredentialManagerOperationFail when pin verification fails`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" val pin = "PIN" @@ -4376,7 +4219,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `PinFido2VerificationSubmit should display Fido2PinError when user has retries remaining`() { + fun `PinUserVerificationSubmit should display UserVerificationPinError when user has retries remaining`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" val pin = "PIN" @@ -4406,7 +4249,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `PinFido2VerificationSubmit should display Fido2ErrorDialog when user has no retries remaining`() { + fun `PinUserVerificationSubmit should display CredentialManagerOperationFail when user has no retries remaining`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" val pin = "PIN" @@ -4436,8 +4279,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `PinFido2VerificationSubmit should display Fido2ErrorDialog when cipher not found`() { + fun `PinUserVerificationSubmit should display CredentialManagerOperationFail when cipher not found`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" val pin = "PIN" @@ -4467,7 +4311,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `PinFido2VerificationSubmit should register credential when pin authenticated successfully`() { + fun `PinUserVerificationSubmit should register credential when pin authenticated successfully`() { setupMockUri() val viewModel = createVaultItemListingViewModel() val cipherView = createMockCipherView(number = 1) @@ -4497,7 +4341,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `RetryFido2PinVerificationClick should display FidoPinPrompt`() { + fun `RetryUserVerificationPinVerificationClick should display FidoPinPrompt`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" @@ -4516,7 +4360,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `PinFido2SetUpSubmit should display Fido2PinSetUpError for empty PIN`() { + fun `UserVerificationPinSetUpSubmit should display Fido2PinSetUpError for empty PIN`() { val viewModel = createVaultItemListingViewModel() val pin = "" val selectedCipherId = "selectedCipherId" @@ -4538,8 +4382,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") @Test - fun `PinFido2SetUpSubmit should save PIN and register credential for non-empty PIN`() { + fun `UserVerificationPinSetUpSubmit should save PIN and register credential for non-empty PIN`() { setupMockUri() val viewModel = createVaultItemListingViewModel() val cipherView = createMockCipherView(number = 1) @@ -4576,7 +4421,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `PinFido2SetUpRetryClick should display Fido2PinSetUpPrompt`() { + fun `UserVerificationPinSetUpRetryClick should display Fido2PinSetUpPrompt`() { val viewModel = createVaultItemListingViewModel() val selectedCipherId = "selectedCipherId" @@ -4595,7 +4440,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `DismissFido2VerificationDialogClick should display Fido2ErrorDialog`() { + fun `DismissUserVerificationDialogClick should display CredentialManagerOperationFail`() { val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( VaultItemListingsAction.DismissUserVerificationDialogClick, @@ -4628,12 +4473,11 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { sendViewList = emptyList(), ), ) - every { - bitwardenCredentialManager.getPasskeyAttestationOptionsOrNull(any()) - } returns createMockPasskeyAttestationOptions( - number = 1, - userVerificationRequirement = UserVerificationRequirement.REQUIRED, - ) + coEvery { + bitwardenCredentialManager.getUserVerificationRequirement( + request = any(), + ) + } returns UserVerificationRequirement.REQUIRED val viewModel = createVaultItemListingViewModel() viewModel.trySendAction( @@ -4647,7 +4491,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `ConfirmOverwriteExistingPasskeyClick should display Fido2ErrorDialog when getSelectedCipher returns null`() = + fun `ConfirmOverwriteExistingPasskeyClick should display CredentialManagerOperationFail when getSelectedCipher returns null`() = runTest { setupMockUri() val cipherView = createMockCipherView(number = 1) @@ -4680,7 +4524,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } - //endregion FIDO2 process handling + //endregion CredentialManager request handling @Test fun `InternetConnectionErrorReceived should show network error if no internet connection`() = @@ -4701,6 +4545,410 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `TrustPrivilegedAppClick during CreateCredentialRequest should clear dialog, trust privileged app, and wait`() = + runTest { + mockkStatic(CallingAppInfo::getSignatureFingerprintAsHexString) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.ProviderCreateCredential( + createCredentialRequest = createMockCreateCredentialRequest(number = 1), + ) + + every { + mockCallingAppInfo.getSignatureFingerprintAsHexString() + } returns "mockSignature" + coEvery { + originManager.validateOrigin(any()) + } returns ValidateOriginResult.Error.PrivilegedAppNotAllowed + + val viewModel = createVaultItemListingViewModel() + + viewModel.trySendAction( + VaultItemListingsAction.TrustPrivilegedAppClick(selectedCipherId = null), + ) + + // Verify the dialog is cleared + assertNull(viewModel.stateFlow.value.dialogState) + + coVerify { + privilegedAppRepository.addTrustedPrivilegedApp( + packageName = "mockPackageName", + signature = "mockSignature", + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `TrustPrivilegedAppClick during CreateCredentialRequest should show CredentialManagerOperationFail dialog when signature is invalid`() = + runTest { + mockkStatic(CallingAppInfo::getSignatureFingerprintAsHexString) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.ProviderCreateCredential( + createCredentialRequest = createMockCreateCredentialRequest(number = 1), + ) + + every { + mockCallingAppInfo.getSignatureFingerprintAsHexString() + } returns null + + val viewModel = createVaultItemListingViewModel() + + viewModel.trySendAction( + VaultItemListingsAction.TrustPrivilegedAppClick(selectedCipherId = null), + ) + + assertEquals( + VaultItemListingState.DialogState.CredentialManagerOperationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_the_request_is_invalid + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + + coVerify(exactly = 0) { + privilegedAppRepository.addTrustedPrivilegedApp( + packageName = any(), + signature = any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `TrustPrivilegedAppClick during BeginGetCredentials should clear dialog, trust privileged app, and get credential entries`() = + runTest { + mockkStatic(CallingAppInfo::getSignatureFingerprintAsHexString) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.ProviderGetCredentials( + getCredentialsRequest = createMockGetCredentialsRequest(number = 1), + ) + + every { + mockCallingAppInfo.getSignatureFingerprintAsHexString() + } returns "mockSignature" + coEvery { + originManager.validateOrigin(mockCallingAppInfo) + } returns ValidateOriginResult.Error.PrivilegedAppNotAllowed + coEvery { + bitwardenCredentialManager.getCredentialEntries(any()) + } returns emptyList().asSuccess() + + val viewModel = createVaultItemListingViewModel() + + viewModel.trySendAction( + VaultItemListingsAction.TrustPrivilegedAppClick(selectedCipherId = null), + ) + + // Verify the dialog is cleared + assertNull(viewModel.stateFlow.value.dialogState) + + coVerify { + privilegedAppRepository.addTrustedPrivilegedApp( + packageName = "mockPackageName", + signature = "mockSignature", + ) + bitwardenCredentialManager.getCredentialEntries( + getCredentialsRequest = any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `TrustPrivilegedAppClick during BeginGetCredentials should show CredentialManagerOperationFail dialog when signature is null`() = + runTest { + mockkStatic(CallingAppInfo::getSignatureFingerprintAsHexString) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.ProviderGetCredentials( + getCredentialsRequest = createMockGetCredentialsRequest(number = 1), + ) + + every { + mockCallingAppInfo.getSignatureFingerprintAsHexString() + } returns null + + val viewModel = createVaultItemListingViewModel() + + viewModel.trySendAction( + VaultItemListingsAction.TrustPrivilegedAppClick(selectedCipherId = null), + ) + + assertEquals( + VaultItemListingState.DialogState.CredentialManagerOperationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_the_request_is_invalid + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + + every { BeginGetCredentialRequest.fromBundle(any()) } returns null + viewModel.trySendAction( + VaultItemListingsAction.TrustPrivilegedAppClick(selectedCipherId = null), + ) + + coVerify(exactly = 0) { + privilegedAppRepository.addTrustedPrivilegedApp( + packageName = any(), + signature = any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `TrustPrivilegedAppClick during BeginGetCredentials should show CredentialManagerOperationFail dialog when callingAppInfo is null`() = + runTest { + mockkStatic(CallingAppInfo::getSignatureFingerprintAsHexString) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.ProviderGetCredentials( + getCredentialsRequest = createMockGetCredentialsRequest(number = 1), + ) + + every { BeginGetCredentialRequest.fromBundle(any()) } returns null + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.TrustPrivilegedAppClick(selectedCipherId = null), + ) + + assertEquals( + VaultItemListingState.DialogState.CredentialManagerOperationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_the_request_is_invalid + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + + coVerify(exactly = 0) { + privilegedAppRepository.addTrustedPrivilegedApp( + packageName = any(), + signature = any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `TrustPrivilegedAppClick during Fido2CredentialAssertion should clear dialog, trust privileged app, and authenticate passkey`() = + runTest { + setupMockUri() + mockkStatic(CallingAppInfo::getSignatureFingerprintAsHexString) + + val cipherView = createMockCipherView( + number = 1, + fido2Credentials = listOf(createMockSdkFido2Credential(number = 1)), + ) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.Fido2Assertion( + fido2AssertionRequest = createMockFido2CredentialAssertionRequest( + number = 1, + cipherId = cipherView.id!!, + ), + ) + + every { + mockCallingAppInfo.getSignatureFingerprintAsHexString() + } returns "mockSignature" + coEvery { + bitwardenCredentialManager.authenticateFido2Credential( + userId = any(), + callingAppInfo = mockCallingAppInfo, + request = any(), + selectedCipherView = any(), + origin = any(), + ) + } returns Fido2CredentialAssertionResult.Success("") + every { + vaultRepository.ciphersStateFlow + } returns MutableStateFlow( + DataState.Loaded( + data = listOf(cipherView), + ), + ) + every { bitwardenCredentialManager.isUserVerified } returns true + coEvery { + originManager.validateOrigin(mockCallingAppInfo) + } returns ValidateOriginResult.Success("mockOrigin") + + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView), + folderViewList = emptyList(), + collectionViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.TrustPrivilegedAppClick( + selectedCipherId = cipherView.id, + ), + ) + + // Verify the dialog is cleared + assertNull(viewModel.stateFlow.value.dialogState) + + coVerify { + privilegedAppRepository.addTrustedPrivilegedApp( + packageName = "mockPackageName", + signature = "mockSignature", + ) + bitwardenCredentialManager.authenticateFido2Credential( + userId = any(), + callingAppInfo = mockCallingAppInfo, + request = any(), + selectedCipherView = any(), + origin = any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `TrustPrivilegedAppClick during Fido2CredentialAssertion should show CredentialManagerOperationFail dialog when signature is null`() = + runTest { + setupMockUri() + mockkStatic(CallingAppInfo::getSignatureFingerprintAsHexString) + + val cipherView = createMockCipherView( + number = 1, + fido2Credentials = listOf(createMockSdkFido2Credential(number = 1)), + ) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.Fido2Assertion( + fido2AssertionRequest = createMockFido2CredentialAssertionRequest( + number = 1, + cipherId = cipherView.id!!, + ), + ) + + every { + vaultRepository.ciphersStateFlow + } returns MutableStateFlow( + DataState.Loaded( + data = listOf(cipherView), + ), + ) + every { + mockCallingAppInfo.getSignatureFingerprintAsHexString() + } returns null + + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView), + folderViewList = emptyList(), + collectionViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.TrustPrivilegedAppClick( + selectedCipherId = cipherView.id, + ), + ) + + assertEquals( + VaultItemListingState.DialogState.CredentialManagerOperationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_the_request_is_invalid + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + + coVerify(exactly = 0) { + privilegedAppRepository.addTrustedPrivilegedApp( + packageName = any(), + signature = any(), + ) + bitwardenCredentialManager.authenticateFido2Credential( + userId = any(), + callingAppInfo = any(), + request = any(), + selectedCipherView = any(), + origin = any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `TrustPrivilegedAppClick during Fido2CredentialAssertion should show CredentialManagerOperationFail dialog when no matching cipher exists`() = + runTest { + setupMockUri() + mockkStatic(CallingAppInfo::getSignatureFingerprintAsHexString) + + val cipherView = createMockCipherView( + number = 1, + fido2Credentials = listOf(createMockSdkFido2Credential(number = 1)), + ) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.Fido2Assertion( + fido2AssertionRequest = createMockFido2CredentialAssertionRequest( + number = 1, + cipherId = cipherView.id!!, + ), + ) + + every { + vaultRepository.ciphersStateFlow + } returns MutableStateFlow( + DataState.Loaded( + data = listOf(cipherView), + ), + ) + every { + mockCallingAppInfo.getSignatureFingerprintAsHexString() + } returns "mockSignature" + + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView), + folderViewList = emptyList(), + collectionViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.TrustPrivilegedAppClick( + selectedCipherId = "noMatchingCipher", + ), + ) + + assertEquals( + VaultItemListingState.DialogState.CredentialManagerOperationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_no_item_was_selected + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + + coVerify(exactly = 0) { + privilegedAppRepository.addTrustedPrivilegedApp( + packageName = any(), + signature = any(), + ) + bitwardenCredentialManager.authenticateFido2Credential( + userId = any(), + callingAppInfo = any(), + request = any(), + selectedCipherView = any(), + origin = any(), + ) + } + } + private fun createSavedStateHandleWithVaultItemListingType( vaultItemListingType: VaultItemListingType, ): SavedStateHandle = SavedStateHandle().apply { @@ -4736,6 +4984,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { organizationEventManager = organizationEventManager, originManager = originManager, networkConnectionManager = networkConnectionManager, + privilegedAppRepository = privilegedAppRepository, ) @Suppress("MaxLineLength")