mirror of
https://github.com/bitwarden/android.git
synced 2025-12-11 04:39:19 -06:00
[PM-19107] Introduce user-trusted privileged apps for Credential Manager (#4848)
This commit is contained in:
parent
f769900976
commit
e1cd813445
@ -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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -9,12 +9,15 @@ import com.bitwarden.sdk.Fido2CredentialStore
|
|||||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||||
import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilder
|
import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilder
|
||||||
import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilderImpl
|
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.BitwardenCredentialManager
|
||||||
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl
|
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl
|
||||||
import com.x8bit.bitwarden.data.credentials.manager.OriginManager
|
import com.x8bit.bitwarden.data.credentials.manager.OriginManager
|
||||||
import com.x8bit.bitwarden.data.credentials.manager.OriginManagerImpl
|
import com.x8bit.bitwarden.data.credentials.manager.OriginManagerImpl
|
||||||
import com.x8bit.bitwarden.data.credentials.processor.CredentialProviderProcessor
|
import com.x8bit.bitwarden.data.credentials.processor.CredentialProviderProcessor
|
||||||
import com.x8bit.bitwarden.data.credentials.processor.CredentialProviderProcessorImpl
|
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.AssetManager
|
||||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
|
||||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||||
@ -85,10 +88,12 @@ object CredentialProviderModule {
|
|||||||
fun provideOriginManager(
|
fun provideOriginManager(
|
||||||
assetManager: AssetManager,
|
assetManager: AssetManager,
|
||||||
digitalAssetLinkService: DigitalAssetLinkService,
|
digitalAssetLinkService: DigitalAssetLinkService,
|
||||||
|
privilegedAppRepository: PrivilegedAppRepository,
|
||||||
): OriginManager =
|
): OriginManager =
|
||||||
OriginManagerImpl(
|
OriginManagerImpl(
|
||||||
assetManager = assetManager,
|
assetManager = assetManager,
|
||||||
digitalAssetLinkService = digitalAssetLinkService,
|
digitalAssetLinkService = digitalAssetLinkService,
|
||||||
|
privilegedAppRepository = privilegedAppRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@ -104,4 +109,14 @@ object CredentialProviderModule {
|
|||||||
featureFlagManager = featureFlagManager,
|
featureFlagManager = featureFlagManager,
|
||||||
biometricsEncryptionManager = biometricsEncryptionManager,
|
biometricsEncryptionManager = biometricsEncryptionManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePrivilegedAppRepository(
|
||||||
|
privilegedAppDiskSource: PrivilegedAppDiskSource,
|
||||||
|
json: Json,
|
||||||
|
): PrivilegedAppRepository = PrivilegedAppRepositoryImpl(
|
||||||
|
privilegedAppDiskSource = privilegedAppDiskSource,
|
||||||
|
json = json,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.credentials.manager
|
|||||||
import androidx.credentials.provider.CallingAppInfo
|
import androidx.credentials.provider.CallingAppInfo
|
||||||
import com.bitwarden.network.service.DigitalAssetLinkService
|
import com.bitwarden.network.service.DigitalAssetLinkService
|
||||||
import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult
|
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.manager.AssetManager
|
||||||
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
|
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
|
||||||
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
|
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(
|
class OriginManagerImpl(
|
||||||
private val assetManager: AssetManager,
|
private val assetManager: AssetManager,
|
||||||
private val digitalAssetLinkService: DigitalAssetLinkService,
|
private val digitalAssetLinkService: DigitalAssetLinkService,
|
||||||
|
private val privilegedAppRepository: PrivilegedAppRepository,
|
||||||
) : OriginManager {
|
) : OriginManager {
|
||||||
|
|
||||||
override suspend fun validateOrigin(
|
override suspend fun validateOrigin(
|
||||||
@ -57,23 +59,12 @@ class OriginManagerImpl(
|
|||||||
|
|
||||||
private suspend fun validatePrivilegedAppOrigin(
|
private suspend fun validatePrivilegedAppOrigin(
|
||||||
callingAppInfo: CallingAppInfo,
|
callingAppInfo: CallingAppInfo,
|
||||||
): ValidateOriginResult {
|
): ValidateOriginResult =
|
||||||
val googleAllowListResult =
|
validatePrivilegedAppSignatureWithGoogleList(callingAppInfo)
|
||||||
validatePrivilegedAppSignatureWithGoogleList(callingAppInfo)
|
.takeUnless { it is ValidateOriginResult.Error }
|
||||||
return when (googleAllowListResult) {
|
?: validatePrivilegedAppSignatureWithCommunityList(callingAppInfo)
|
||||||
is ValidateOriginResult.Success -> {
|
.takeUnless { it is ValidateOriginResult.Error }
|
||||||
// Application was found and successfully validated against the Google allow list so
|
?: validatePrivilegedAppSignatureWithUserTrustList(callingAppInfo)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun validatePrivilegedAppSignatureWithGoogleList(
|
private suspend fun validatePrivilegedAppSignatureWithGoogleList(
|
||||||
callingAppInfo: CallingAppInfo,
|
callingAppInfo: CallingAppInfo,
|
||||||
@ -85,11 +76,16 @@ class OriginManagerImpl(
|
|||||||
|
|
||||||
private suspend fun validatePrivilegedAppSignatureWithCommunityList(
|
private suspend fun validatePrivilegedAppSignatureWithCommunityList(
|
||||||
callingAppInfo: CallingAppInfo,
|
callingAppInfo: CallingAppInfo,
|
||||||
): ValidateOriginResult =
|
): ValidateOriginResult = validatePrivilegedAppSignatureWithAllowList(
|
||||||
validatePrivilegedAppSignatureWithAllowList(
|
callingAppInfo = callingAppInfo,
|
||||||
callingAppInfo = callingAppInfo,
|
fileName = COMMUNITY_ALLOW_LIST_FILE_NAME,
|
||||||
fileName = COMMUNITY_ALLOW_LIST_FILE_NAME,
|
)
|
||||||
)
|
|
||||||
|
private suspend fun validatePrivilegedAppSignatureWithUserTrustList(
|
||||||
|
callingAppInfo: CallingAppInfo,
|
||||||
|
): ValidateOriginResult = callingAppInfo.validatePrivilegedApp(
|
||||||
|
allowList = privilegedAppRepository.getUserTrustedAllowListJson(),
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun validatePrivilegedAppSignatureWithAllowList(
|
private suspend fun validatePrivilegedAppSignatureWithAllowList(
|
||||||
callingAppInfo: CallingAppInfo,
|
callingAppInfo: CallingAppInfo,
|
||||||
|
|||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
@ -23,7 +23,7 @@ fun CallingAppInfo.getSignatureFingerprintAsHexString(): String? {
|
|||||||
*/
|
*/
|
||||||
fun CallingAppInfo.validatePrivilegedApp(allowList: String): ValidateOriginResult {
|
fun CallingAppInfo.validatePrivilegedApp(allowList: String): ValidateOriginResult {
|
||||||
|
|
||||||
if (!allowList.contains("\"package_name\": \"$packageName\"")) {
|
if (!allowList.contains("\"$packageName\"")) {
|
||||||
return ValidateOriginResult.Error.PrivilegedAppNotAllowed
|
return ValidateOriginResult.Error.PrivilegedAppNotAllowed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.fab.BitwardenFloatingActionButton
|
||||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||||
import com.bitwarden.ui.util.Text
|
import com.bitwarden.ui.util.Text
|
||||||
|
import com.bitwarden.ui.util.asText
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.ui.credentials.manager.CredentialProviderCompletionManager
|
import com.x8bit.bitwarden.ui.credentials.manager.CredentialProviderCompletionManager
|
||||||
import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem
|
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.BitwardenMasterPasswordDialog
|
||||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog
|
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.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.BitwardenPullToRefreshState
|
||||||
import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState
|
import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState
|
||||||
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||||
@ -223,7 +225,7 @@ fun VaultItemListingScreen(
|
|||||||
onDismissRequest = remember(viewModel) {
|
onDismissRequest = remember(viewModel) {
|
||||||
{ viewModel.trySendAction(VaultItemListingsAction.DismissDialogClick) }
|
{ viewModel.trySendAction(VaultItemListingsAction.DismissDialogClick) }
|
||||||
},
|
},
|
||||||
onDismissFido2ErrorDialog = remember(viewModel) {
|
onDismissCredentialManagerErrorDialog = remember(viewModel) {
|
||||||
{ errorMessage ->
|
{ errorMessage ->
|
||||||
viewModel.trySendAction(
|
viewModel.trySendAction(
|
||||||
VaultItemListingsAction
|
VaultItemListingsAction
|
||||||
@ -307,6 +309,13 @@ fun VaultItemListingScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onTrustPrivilegedAppClick = remember(viewModel) {
|
||||||
|
{
|
||||||
|
viewModel.trySendAction(
|
||||||
|
VaultItemListingsAction.TrustPrivilegedAppClick(it),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
val vaultItemListingHandlers = remember(viewModel) {
|
val vaultItemListingHandlers = remember(viewModel) {
|
||||||
@ -326,7 +335,7 @@ fun VaultItemListingScreen(
|
|||||||
private fun VaultItemListingDialogs(
|
private fun VaultItemListingDialogs(
|
||||||
dialogState: VaultItemListingState.DialogState?,
|
dialogState: VaultItemListingState.DialogState?,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onDismissFido2ErrorDialog: (Text) -> Unit,
|
onDismissCredentialManagerErrorDialog: (Text) -> Unit,
|
||||||
onConfirmOverwriteExistingPasskey: (cipherViewId: String) -> Unit,
|
onConfirmOverwriteExistingPasskey: (cipherViewId: String) -> Unit,
|
||||||
onSubmitMasterPasswordFido2Verification: (password: String, cipherId: String) -> Unit,
|
onSubmitMasterPasswordFido2Verification: (password: String, cipherId: String) -> Unit,
|
||||||
onRetryFido2PasswordVerification: (cipherId: String) -> Unit,
|
onRetryFido2PasswordVerification: (cipherId: String) -> Unit,
|
||||||
@ -336,6 +345,7 @@ private fun VaultItemListingDialogs(
|
|||||||
onRetryPinSetUpFido2Verification: (cipherId: String) -> Unit,
|
onRetryPinSetUpFido2Verification: (cipherId: String) -> Unit,
|
||||||
onDismissFido2Verification: () -> Unit,
|
onDismissFido2Verification: () -> Unit,
|
||||||
onVaultItemTypeSelected: (CreateVaultItemType) -> Unit,
|
onVaultItemTypeSelected: (CreateVaultItemType) -> Unit,
|
||||||
|
onTrustPrivilegedAppClick: (selectedCipherId: String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (dialogState) {
|
when (dialogState) {
|
||||||
is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog(
|
is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog(
|
||||||
@ -352,7 +362,7 @@ private fun VaultItemListingDialogs(
|
|||||||
is VaultItemListingState.DialogState.CredentialManagerOperationFail -> BitwardenBasicDialog(
|
is VaultItemListingState.DialogState.CredentialManagerOperationFail -> BitwardenBasicDialog(
|
||||||
title = dialogState.title(),
|
title = dialogState.title(),
|
||||||
message = dialogState.message(),
|
message = dialogState.message(),
|
||||||
onDismissRequest = { onDismissFido2ErrorDialog(dialogState.message) },
|
onDismissRequest = { onDismissCredentialManagerErrorDialog(dialogState.message) },
|
||||||
)
|
)
|
||||||
|
|
||||||
is VaultItemListingState.DialogState.OverwritePasskeyConfirmationPrompt -> {
|
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
|
null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.credentials.GetPublicKeyCredentialOption
|
import androidx.credentials.GetPublicKeyCredentialOption
|
||||||
|
import androidx.credentials.provider.CallingAppInfo
|
||||||
import androidx.credentials.provider.CredentialEntry
|
import androidx.credentials.provider.CredentialEntry
|
||||||
import androidx.credentials.provider.ProviderCreateCredentialRequest
|
import androidx.credentials.provider.ProviderCreateCredentialRequest
|
||||||
import androidx.credentials.provider.ProviderGetCredentialRequest
|
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.GetCredentialsRequest
|
||||||
import com.x8bit.bitwarden.data.credentials.model.UserVerificationRequirement
|
import com.x8bit.bitwarden.data.credentials.model.UserVerificationRequirement
|
||||||
import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult
|
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.credentials.util.getCreatePasskeyCredentialRequestOrNull
|
||||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
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.manager.util.toTotpDataOrNull
|
||||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
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.VaultRepository
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult
|
import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
|
||||||
@ -119,6 +122,7 @@ class VaultItemListingViewModel @Inject constructor(
|
|||||||
private val vaultRepository: VaultRepository,
|
private val vaultRepository: VaultRepository,
|
||||||
private val environmentRepository: EnvironmentRepository,
|
private val environmentRepository: EnvironmentRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val privilegedAppRepository: PrivilegedAppRepository,
|
||||||
private val accessibilitySelectionManager: AccessibilitySelectionManager,
|
private val accessibilitySelectionManager: AccessibilitySelectionManager,
|
||||||
private val autofillSelectionManager: AutofillSelectionManager,
|
private val autofillSelectionManager: AutofillSelectionManager,
|
||||||
private val cipherMatchingManager: CipherMatchingManager,
|
private val cipherMatchingManager: CipherMatchingManager,
|
||||||
@ -290,10 +294,15 @@ class VaultItemListingViewModel @Inject constructor(
|
|||||||
handleUserVerificationNotSupported(action)
|
handleUserVerificationNotSupported(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
is VaultItemListingsAction.Internal -> handleInternalAction(action)
|
|
||||||
is VaultItemListingsAction.ItemTypeToAddSelected -> {
|
is VaultItemListingsAction.ItemTypeToAddSelected -> {
|
||||||
handleItemTypeToAddSelected(action)
|
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() {
|
private fun handleAddVaultItemClick() {
|
||||||
when (val itemListingType = state.itemListingType) {
|
when (val itemListingType = state.itemListingType) {
|
||||||
is VaultItemListingState.ItemListingType.Vault.Collection -> {
|
is VaultItemListingState.ItemListingType.Vault.Collection -> {
|
||||||
@ -930,7 +1045,11 @@ class VaultItemListingViewModel @Inject constructor(
|
|||||||
|
|
||||||
when (validateOriginResult) {
|
when (validateOriginResult) {
|
||||||
is ValidateOriginResult.Error -> {
|
is ValidateOriginResult.Error -> {
|
||||||
handleOriginValidationFail(validateOriginResult)
|
handleOriginValidationFail(
|
||||||
|
error = validateOriginResult,
|
||||||
|
callingAppInfo = request.callingAppInfo,
|
||||||
|
selectedCipherId = cipherView.id,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is ValidateOriginResult.Success -> {
|
is ValidateOriginResult.Success -> {
|
||||||
@ -1670,7 +1789,11 @@ class VaultItemListingViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
when (validateOriginResult) {
|
when (validateOriginResult) {
|
||||||
is ValidateOriginResult.Error -> {
|
is ValidateOriginResult.Error -> {
|
||||||
handleOriginValidationFail(validateOriginResult)
|
handleOriginValidationFail(
|
||||||
|
error = validateOriginResult,
|
||||||
|
callingAppInfo = action.request.callingAppInfo,
|
||||||
|
selectedCipherId = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is ValidateOriginResult.Success -> {
|
is ValidateOriginResult.Success -> {
|
||||||
@ -1741,20 +1864,41 @@ class VaultItemListingViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is ValidateOriginResult.Error -> {
|
is ValidateOriginResult.Error -> {
|
||||||
handleOriginValidationFail(validateOriginResult)
|
handleOriginValidationFail(
|
||||||
|
error = validateOriginResult,
|
||||||
|
callingAppInfo = callingAppInfo,
|
||||||
|
selectedCipherId = null,
|
||||||
|
)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleOriginValidationFail(error: ValidateOriginResult.Error) {
|
private fun handleOriginValidationFail(
|
||||||
|
error: ValidateOriginResult.Error,
|
||||||
|
callingAppInfo: CallingAppInfo,
|
||||||
|
selectedCipherId: String?,
|
||||||
|
) {
|
||||||
mutableStateFlow.update {
|
mutableStateFlow.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
dialogState = VaultItemListingState.DialogState.CredentialManagerOperationFail(
|
dialogState = when (error) {
|
||||||
title = R.string.an_error_has_occurred.asText(),
|
is ValidateOriginResult.Error.PrivilegedAppNotAllowed -> {
|
||||||
message = error.messageResourceId.asText(),
|
@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(
|
data class VaultItemTypeSelection(
|
||||||
val excludedOptions: ImmutableList<CreateVaultItemType>,
|
val excludedOptions: ImmutableList<CreateVaultItemType>,
|
||||||
) : DialogState()
|
) : 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,
|
val itemType: CreateVaultItemType,
|
||||||
) : VaultItemListingsAction()
|
) : 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.
|
* Models actions that the [VaultItemListingViewModel] itself might send.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -963,4 +963,7 @@ Do you want to switch to this account?</string>
|
|||||||
<string name="dynamic_colors">Dynamic colors</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_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="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>
|
</resources>
|
||||||
|
|||||||
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import com.bitwarden.core.data.util.asSuccess
|
|||||||
import com.bitwarden.network.model.DigitalAssetLinkCheckResponseJson
|
import com.bitwarden.network.model.DigitalAssetLinkCheckResponseJson
|
||||||
import com.bitwarden.network.service.DigitalAssetLinkService
|
import com.bitwarden.network.service.DigitalAssetLinkService
|
||||||
import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult
|
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.manager.AssetManager
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
@ -40,6 +41,7 @@ class OriginManagerTest {
|
|||||||
every { hasMultipleSigners() } returns false
|
every { hasMultipleSigners() } returns false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private val mockPrivilegedAppRepository = mockk<PrivilegedAppRepository>()
|
||||||
private val mockMessageDigest = mockk<MessageDigest> {
|
private val mockMessageDigest = mockk<MessageDigest> {
|
||||||
every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray()
|
every { digest(any()) } returns DEFAULT_APP_SIGNATURE.toByteArray()
|
||||||
}
|
}
|
||||||
@ -47,6 +49,7 @@ class OriginManagerTest {
|
|||||||
private val fido2OriginManager = OriginManagerImpl(
|
private val fido2OriginManager = OriginManagerImpl(
|
||||||
assetManager = mockAssetManager,
|
assetManager = mockAssetManager,
|
||||||
digitalAssetLinkService = mockDigitalAssetLinkService,
|
digitalAssetLinkService = mockDigitalAssetLinkService,
|
||||||
|
privilegedAppRepository = mockPrivilegedAppRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@ -112,7 +115,7 @@ class OriginManagerTest {
|
|||||||
|
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
@Test
|
@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 {
|
runTest {
|
||||||
coEvery {
|
coEvery {
|
||||||
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
|
mockAssetManager.readAsset(GOOGLE_ALLOW_LIST_FILENAME)
|
||||||
@ -120,6 +123,37 @@ class OriginManagerTest {
|
|||||||
coEvery {
|
coEvery {
|
||||||
mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME)
|
mockAssetManager.readAsset(COMMUNITY_ALLOW_LIST_FILENAME)
|
||||||
} returns FAIL_ALLOW_LIST.asSuccess()
|
} 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(
|
val result = fido2OriginManager.validateOrigin(
|
||||||
callingAppInfo = mockPrivilegedAppInfo,
|
callingAppInfo = mockPrivilegedAppInfo,
|
||||||
@ -194,25 +228,6 @@ class OriginManagerTest {
|
|||||||
fido2OriginManager.validateOrigin(callingAppInfo = mockNonPrivilegedAppInfo),
|
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"
|
private const val DEFAULT_PACKAGE_NAME = "com.x8bit.bitwarden"
|
||||||
|
|||||||
@ -5,10 +5,11 @@ import androidx.core.os.bundleOf
|
|||||||
fun createMockFido2CredentialAssertionRequest(
|
fun createMockFido2CredentialAssertionRequest(
|
||||||
number: Int = 1,
|
number: Int = 1,
|
||||||
userId: String = "mockUserId-$number",
|
userId: String = "mockUserId-$number",
|
||||||
|
cipherId: String = "mockCipherId-$number",
|
||||||
): Fido2CredentialAssertionRequest =
|
): Fido2CredentialAssertionRequest =
|
||||||
Fido2CredentialAssertionRequest(
|
Fido2CredentialAssertionRequest(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
cipherId = "mockCipherId-$number",
|
cipherId = cipherId,
|
||||||
credentialId = "mockCredentialId-$number",
|
credentialId = "mockCredentialId-$number",
|
||||||
isUserPreVerified = false,
|
isUserPreVerified = false,
|
||||||
requestData = bundleOf(),
|
requestData = bundleOf(),
|
||||||
|
|||||||
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
@ -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(
|
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user