[PM-19107] Introduce user-trusted privileged apps for Credential Manager (#4848)

This commit is contained in:
Patrick Honkonen 2025-06-06 13:51:06 -04:00 committed by GitHub
parent f769900976
commit e1cd813445
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1676 additions and 365 deletions

View File

@ -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')"
]
}
}

View File

@ -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<List<PrivilegedAppEntity>>
/**
* Retrieves all the user's trusted privileged apps.
*/
suspend fun getAllUserTrustedPrivilegedApps(): List<PrivilegedAppEntity>
/**
* 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
}

View File

@ -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<List<PrivilegedAppEntity>> =
privilegedAppDao.getUserTrustedPrivilegedAppsFlow()
override suspend fun getAllUserTrustedPrivilegedApps(): List<PrivilegedAppEntity> {
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,
)
}
}

View File

@ -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<List<PrivilegedAppEntity>>
/**
* Retrieves all the trusted privileged apps.
*/
@Query("SELECT * FROM privileged_apps")
suspend fun getAllUserTrustedPrivilegedApps(): List<PrivilegedAppEntity>
/**
* 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<PrivilegedAppJson>,
) {
/**
* 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<SignatureJson>,
) {
/**
* Models the privileged application signature JSON.
*/
@Serializable
data class SignatureJson(
@SerialName("build")
val build: String,
@SerialName("cert_fingerprint_sha256")
val certFingerprintSha256: String,
)
}
}
}

View File

@ -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<PrivilegedAppAllowListJson>
/**
* 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
}

View File

@ -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<PrivilegedAppAllowListJson> =
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<PrivilegedAppEntity>.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,
),
),
),
)

View File

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

View File

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

View File

@ -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<CreateVaultItemType>,
) : 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.
*/

View File

@ -963,4 +963,7 @@ Do you want to switch to this account?</string>
<string name="dynamic_colors">Dynamic colors</string>
<string name="dynamic_colors_description">Apply dynamic colors based on your wallpaper</string>
<string name="dynamic_colors_may_not_adhere_to_accessibility_guidelines">Dynamic colors uses the system colors and may not meet all accessibility guidelines.</string>
<string name="passkey_operation_failed_because_browser_x_is_not_trusted">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.</string>
<string name="passkey_operation_failed_because_the_browser_is_not_trusted">Passkey operation failed because the browser is not trusted.</string>
<string name="trust">Trust</string>
</resources>

View File

@ -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<List<PrivilegedAppEntity>>(emptyList())
private val mockPrivilegedAppDao = mockk<PrivilegedAppDao> {
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<PrivilegedAppEntity>(), awaitItem())
mutableUserTrustedPrivilegedAppsFlow.emit(
listOf(
PrivilegedAppEntity(
packageName = "mockPackageName",
signature = "mockSignature",
),
),
)
// Verify the updated state is correct
assertEquals(
listOf(
PrivilegedAppEntity(
packageName = "mockPackageName",
signature = "mockSignature",
),
),
awaitItem(),
)
}
}
}

View File

@ -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<PrivilegedAppRepository>()
private val mockMessageDigest = mockk<MessageDigest> {
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"

View File

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

View File

@ -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<List<PrivilegedAppEntity>>(emptyList())
private val mockPrivilegedAppDiskSource = mockk<PrivilegedAppDiskSource> {
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<Json>()
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",
),
),
),
)

View File

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