mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 09:56:45 -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.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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
if (!allowList.contains("\"package_name\": \"$packageName\"")) {
|
||||
if (!allowList.contains("\"$packageName\"")) {
|
||||
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.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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.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"
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user