From 7f4e65d7e4273b0157bf1efafcb0bcde867a3099 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 13 May 2025 17:18:16 -0400 Subject: [PATCH] [PM-21567] Implement `CredentialEntryBuilder` interface (#5177) --- .../builder/CredentialEntryBuilder.kt | 21 ++ .../builder/CredentialEntryBuilderImpl.kt | 96 +++++++ .../di/CredentialProviderModule.kt | 29 +- .../manager/BitwardenCredentialManager.kt | 7 +- .../manager/BitwardenCredentialManagerImpl.kt | 237 +++------------- .../model/GetCredentialsRequest.kt | 15 +- .../ProviderGetPasswordCredentialRequest.kt | 23 ++ .../CredentialProviderProcessorImpl.kt | 32 +-- .../util/BiometricPromptDataUtils.kt | 22 ++ ...blicKeyCredentialEntryBuilderExtensions.kt | 27 ++ .../itemlisting/VaultItemListingViewModel.kt | 58 ++-- .../builder/CredentialEntryBuilderTest.kt | 243 ++++++++++++++++ .../manager/BitwardenCredentialManagerTest.kt | 265 +++++++----------- .../CredentialProviderProcessorTest.kt | 9 +- .../datasource/sdk/model/CipherViewUtil.kt | 8 +- .../VaultItemListingViewModelTest.kt | 16 +- 16 files changed, 671 insertions(+), 437 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilder.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilderImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/credentials/model/ProviderGetPasswordCredentialRequest.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/credentials/util/BiometricPromptDataUtils.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/credentials/util/PublicKeyCredentialEntryBuilderExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilderTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilder.kt b/app/src/main/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilder.kt new file mode 100644 index 0000000000..89831162c1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilder.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.data.credentials.builder + +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.PublicKeyCredentialEntry +import com.bitwarden.fido.Fido2CredentialAutofillView + +/** + * Builder for credential entries. + */ +interface CredentialEntryBuilder { + + /** + * Build public key credential entries from the given cipher views and options. + */ + fun buildPublicKeyCredentialEntries( + userId: String, + fido2CredentialAutofillViews: List, + beginGetPublicKeyCredentialOptions: List, + isUserVerified: Boolean, + ): List +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilderImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilderImpl.kt new file mode 100644 index 0000000000..a680910b53 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilderImpl.kt @@ -0,0 +1,96 @@ +package com.x8bit.bitwarden.data.credentials.builder + +import android.content.Context +import android.graphics.drawable.Icon +import androidx.core.graphics.drawable.IconCompat +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.PublicKeyCredentialEntry +import com.bitwarden.fido.Fido2CredentialAutofillView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.credentials.processor.GET_PASSKEY_INTENT +import com.x8bit.bitwarden.data.credentials.util.setBiometricPromptDataIfSupported +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import kotlin.random.Random + +/** + * Primary implementation of [CredentialEntryBuilder]. + */ +class CredentialEntryBuilderImpl( + private val context: Context, + private val intentManager: IntentManager, + private val featureFlagManager: FeatureFlagManager, + private val biometricsEncryptionManager: BiometricsEncryptionManager, +) : CredentialEntryBuilder { + + override fun buildPublicKeyCredentialEntries( + userId: String, + fido2CredentialAutofillViews: List, + beginGetPublicKeyCredentialOptions: List, + isUserVerified: Boolean, + ): List = beginGetPublicKeyCredentialOptions + .flatMap { option -> + fido2CredentialAutofillViews + .toPublicKeyCredentialEntryList( + userId = userId, + option = option, + isUserVerified = isUserVerified, + ) + } + + private fun List.toPublicKeyCredentialEntryList( + userId: String, + option: BeginGetPublicKeyCredentialOption, + isUserVerified: Boolean, + ): List = this + .map { fido2AutofillView -> + PublicKeyCredentialEntry + .Builder( + context = context, + username = fido2AutofillView.userNameForUi + ?: context.getString(R.string.no_username), + pendingIntent = intentManager + .createFido2GetCredentialPendingIntent( + action = GET_PASSKEY_INTENT, + userId = userId, + credentialId = fido2AutofillView.credentialId.toString(), + cipherId = fido2AutofillView.cipherId, + isUserVerified = isUserVerified, + requestCode = Random.nextInt(), + ), + beginGetPublicKeyCredentialOption = option, + ) + .setIcon( + getCredentialEntryIcon( + isPasskey = true, + ), + ) + .also { builder -> + if (!isUserVerified) { + builder.setBiometricPromptDataIfSupported( + cipher = biometricsEncryptionManager + .getOrCreateCipher(userId), + isSingleTapAuthEnabled = featureFlagManager + .getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication), + ) + } + } + .build() + } + + // TODO: [PM-20176] Enable web icons in credential entries + // Leave web icons disabled until CredentialManager TransactionTooLargeExceptions + // are addressed. See https://issuetracker.google.com/issues/355141766 for details. + private fun getCredentialEntryIcon(isPasskey: Boolean): Icon = IconCompat + .createWithResource( + context, + if (isPasskey) { + R.drawable.ic_bw_passkey + } else { + R.drawable.ic_globe + }, + ) + .toIcon(context) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt index bf4fc5f626..41bfb715ac 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/credentials/di/CredentialProviderModule.kt @@ -7,6 +7,8 @@ import com.bitwarden.data.manager.DispatcherManager import com.bitwarden.network.service.DigitalAssetLinkService 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.manager.BitwardenCredentialManager import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl import com.x8bit.bitwarden.data.credentials.manager.OriginManager @@ -16,7 +18,6 @@ import com.x8bit.bitwarden.data.credentials.processor.CredentialProviderProcesso import com.x8bit.bitwarden.data.platform.manager.AssetManager import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager -import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @@ -63,28 +64,20 @@ object CredentialProviderModule { @Provides @Singleton fun provideBitwardenCredentialManager( - @ApplicationContext context: Context, - intentManager: IntentManager, - featureFlagManager: FeatureFlagManager, - biometricsEncryptionManager: BiometricsEncryptionManager, vaultSdkSource: VaultSdkSource, fido2CredentialStore: Fido2CredentialStore, json: Json, - environmentRepository: EnvironmentRepository, vaultRepository: VaultRepository, dispatcherManager: DispatcherManager, + credentialEntryBuilder: CredentialEntryBuilder, ): BitwardenCredentialManager = BitwardenCredentialManagerImpl( - context = context, vaultSdkSource = vaultSdkSource, fido2CredentialStore = fido2CredentialStore, - intentManager = intentManager, - featureFlagManager = featureFlagManager, - biometricsEncryptionManager = biometricsEncryptionManager, json = json, - environmentRepository = environmentRepository, vaultRepository = vaultRepository, dispatcherManager = dispatcherManager, + credentialEntryBuilder = credentialEntryBuilder, ) @Provides @@ -97,4 +90,18 @@ object CredentialProviderModule { assetManager = assetManager, digitalAssetLinkService = digitalAssetLinkService, ) + + @Provides + @Singleton + fun provideCredentialEntryBuilder( + @ApplicationContext context: Context, + intentManager: IntentManager, + featureFlagManager: FeatureFlagManager, + biometricsEncryptionManager: BiometricsEncryptionManager, + ): CredentialEntryBuilder = CredentialEntryBuilderImpl( + context = context, + intentManager = intentManager, + featureFlagManager = featureFlagManager, + biometricsEncryptionManager = biometricsEncryptionManager, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManager.kt index 95c39f8c9a..1eab518f50 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManager.kt @@ -2,13 +2,13 @@ package com.x8bit.bitwarden.data.credentials.manager import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption -import androidx.credentials.provider.BeginGetCredentialOption import androidx.credentials.provider.CallingAppInfo import androidx.credentials.provider.CredentialEntry import androidx.credentials.provider.ProviderGetCredentialRequest import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionResult import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult +import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest import com.x8bit.bitwarden.data.credentials.model.PasskeyAttestationOptions import com.x8bit.bitwarden.data.credentials.model.UserVerificationRequirement @@ -91,10 +91,9 @@ interface BitwardenCredentialManager { /** * Retrieve a list of [CredentialEntry] objects representing vault items matching the given - * request [options]. + * [getCredentialsRequest]. */ suspend fun getCredentialEntries( - userId: String, - options: List, + getCredentialsRequest: GetCredentialsRequest, ): Result> } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManagerImpl.kt index 5fd8e6a2ed..bca98e1a4a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManagerImpl.kt @@ -1,52 +1,34 @@ package com.x8bit.bitwarden.data.credentials.manager -import android.content.Context -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.annotation.WorkerThread -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.core.graphics.drawable.IconCompat import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.exceptions.GetCredentialUnknownException -import androidx.credentials.provider.BeginGetCredentialOption import androidx.credentials.provider.BeginGetPublicKeyCredentialOption -import androidx.credentials.provider.BiometricPromptData import androidx.credentials.provider.CallingAppInfo import androidx.credentials.provider.CredentialEntry import androidx.credentials.provider.ProviderGetCredentialRequest -import androidx.credentials.provider.PublicKeyCredentialEntry -import com.bitwarden.core.annotation.OmitFromCoverage import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.takeUntilLoaded import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess import com.bitwarden.core.data.util.decodeFromStringOrNull import com.bitwarden.data.manager.DispatcherManager -import com.bitwarden.data.repository.util.baseIconUrl import com.bitwarden.fido.ClientData -import com.bitwarden.fido.Fido2CredentialAutofillView import com.bitwarden.fido.Origin import com.bitwarden.fido.UnverifiedAssetLink import com.bitwarden.sdk.Fido2CredentialStore import com.bitwarden.vault.CipherView -import com.bumptech.glide.Glide -import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials +import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilder import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionResult import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult +import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest import com.x8bit.bitwarden.data.credentials.model.PasskeyAssertionOptions import com.x8bit.bitwarden.data.credentials.model.PasskeyAttestationOptions import com.x8bit.bitwarden.data.credentials.model.UserVerificationRequirement -import com.x8bit.bitwarden.data.credentials.processor.GET_PASSKEY_INTENT -import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager -import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager -import com.x8bit.bitwarden.data.platform.manager.model.FlagKey -import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.util.getAppOrigin import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString -import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest @@ -55,32 +37,22 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidFido2PublicKe import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult import com.x8bit.bitwarden.ui.platform.base.util.prefixHttpsIfNecessaryOrNull -import com.x8bit.bitwarden.ui.platform.components.model.IconData -import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager -import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.fold import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import timber.log.Timber -import java.util.concurrent.ExecutionException -import javax.crypto.Cipher -import kotlin.random.Random /** * Primary implementation of [BitwardenCredentialManager]. */ @Suppress("TooManyFunctions", "LongParameterList") class BitwardenCredentialManagerImpl( - private val context: Context, private val vaultSdkSource: VaultSdkSource, private val fido2CredentialStore: Fido2CredentialStore, - private val intentManager: IntentManager, - private val featureFlagManager: FeatureFlagManager, - private val biometricsEncryptionManager: BiometricsEncryptionManager, + private val credentialEntryBuilder: CredentialEntryBuilder, private val json: Json, private val vaultRepository: VaultRepository, - private val environmentRepository: EnvironmentRepository, dispatcherManager: DispatcherManager, ) : BitwardenCredentialManager, Fido2CredentialStore by fido2CredentialStore { @@ -194,8 +166,7 @@ class BitwardenCredentialManagerImpl( ?: fallbackRequirement override suspend fun getCredentialEntries( - userId: String, - options: List, + getCredentialsRequest: GetCredentialsRequest, ): Result> = withContext(ioScope.coroutineContext) { val cipherViews = vaultRepository .ciphersStateFlow @@ -209,33 +180,18 @@ class BitwardenCredentialManagerImpl( else -> emptyList() } } + .filter { it.isActiveWithFido2Credentials } .ifEmpty { return@withContext emptyList().asSuccess() } - val publicKeyCredentialOptions = options - .filterIsInstance() - .ifEmpty { return@withContext emptyList().asSuccess() } - - when ( - val decryptResult = - vaultRepository.getDecryptedFido2CredentialAutofillViews(cipherViews) - ) { - is DecryptFido2CredentialAutofillViewResult.Error -> { - GetCredentialUnknownException( - "Error decrypting user's FIDO 2 credentials.", - ) - .asFailure() - } - - is DecryptFido2CredentialAutofillViewResult.Success -> { - publicKeyCredentialOptions.toPublicKeyCredentialEntries( - userId = userId, - cipherViews = cipherViews, - fido2CredentialAutofillViews = decryptResult.fido2CredentialAutofillViews, - ) - } - } + getCredentialsRequest + .beginGetPublicKeyCredentialOptions + .toPublicKeyCredentialEntries( + userId = getCredentialsRequest.userId, + cipherViewsWithPublicKeyCredentials = cipherViews, + ) + .onFailure { Timber.e(it, "Failed to get FIDO 2 credential entries.") } } private fun getPasskeyAssertionOptionsOrNull( @@ -244,157 +200,38 @@ class BitwardenCredentialManagerImpl( private suspend fun List.toPublicKeyCredentialEntries( userId: String, - cipherViews: List, - fido2CredentialAutofillViews: List, - ): Result> { - val baseIconUrl = environmentRepository - .environment - .environmentUrlData - .baseIconUrl + cipherViewsWithPublicKeyCredentials: List, + ): Result> { + val relyingPartyIds = this + .mapNotNull { getPasskeyAssertionOptionsOrNull(it.requestJson)?.relyingPartyId } + .distinct() + .ifEmpty { + return GetCredentialUnknownException("Relying party id required.").asFailure() + } - var options: PasskeyAssertionOptions - var relyingPartyId: String - return this - .flatMap { option -> - options = getPasskeyAssertionOptionsOrNull(option.requestJson) - ?: return GetCredentialUnknownException( - "Invalid passkey request. Could not deserialize request options.", - ) - .asFailure() + val decryptResult = vaultRepository + .getDecryptedFido2CredentialAutofillViews(cipherViewsWithPublicKeyCredentials) - relyingPartyId = options.relyingPartyId - ?: return GetCredentialUnknownException( - "Invalid passkey request. Relying party ID is required.", - ) - .asFailure() + return when (decryptResult) { + is DecryptFido2CredentialAutofillViewResult.Error -> { + GetCredentialUnknownException("Error decrypting credentials.").asFailure() + } - val autofillViews = fido2CredentialAutofillViews - .filter { it.rpId == relyingPartyId } - .ifEmpty { - return emptyList().asSuccess() - } - - val cipherIdsToMatch = autofillViews - .map { it.cipherId } - .toSet() - - cipherViews - .filter { cipherView -> cipherView.id in cipherIdsToMatch } - .associateWith { cipherView -> - // We can safely call first() here because we know the cipherId exists in - // the collection of autofill views. - autofillViews.first { it.cipherId == cipherView.id } - } - .toPublicKeyCredentialEntryList( - baseIconUrl = baseIconUrl, + is DecryptFido2CredentialAutofillViewResult.Success -> { + credentialEntryBuilder + .buildPublicKeyCredentialEntries( userId = userId, - option = option, - ) - } - .asSuccess() - } - - private suspend fun Map.toPublicKeyCredentialEntryList( - baseIconUrl: String, - userId: String, - option: BeginGetPublicKeyCredentialOption, - ): List = this.map { (cipherView, autofillView) -> - val loginIconData = cipherView.login - ?.uris - .toLoginIconData( - // TODO: [PM-20176] Enable web icons in passkey credential entries - // Leave web icons disabled until CredentialManager TransactionTooLargeExceptions - // are addressed. See https://issuetracker.google.com/issues/355141766 for details. - isIconLoadingDisabled = true, - baseIconUrl = baseIconUrl, - usePasskeyDefaultIcon = true, - ) - val iconCompat = when (loginIconData) { - is IconData.Local -> { - IconCompat.createWithResource(context, loginIconData.iconRes) - } - - is IconData.Network -> { - loginIconData.toIconCompat() - } - } - - val pkEntryBuilder = PublicKeyCredentialEntry - .Builder( - context = context, - username = autofillView.userNameForUi - ?: context.getString(R.string.no_username), - pendingIntent = intentManager - .createFido2GetCredentialPendingIntent( - action = GET_PASSKEY_INTENT, - userId = userId, - credentialId = autofillView.credentialId.toString(), - cipherId = autofillView.cipherId, + fido2CredentialAutofillViews = decryptResult + .fido2CredentialAutofillViews + .filter { it.rpId in relyingPartyIds }, + beginGetPublicKeyCredentialOptions = this, isUserVerified = isUserVerified, - requestCode = Random.nextInt(), - ), - beginGetPublicKeyCredentialOption = option, - ) - .setIcon(iconCompat.toIcon(context)) - - if (featureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication) && - !isUserVerified - ) { - biometricsEncryptionManager - .getOrCreateCipher(userId) - ?.let { cipher -> - pkEntryBuilder - .setBiometricPromptDataIfSupported(cipher = cipher) - } + ) + .asSuccess() + } } - - pkEntryBuilder.build() } - private fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported( - cipher: Cipher, - ): PublicKeyCredentialEntry.Builder = - if (isBuildVersionBelow(Build.VERSION_CODES.VANILLA_ICE_CREAM)) { - this - } else { - setBiometricPromptData( - biometricPromptData = buildPromptDataWithCipher(cipher), - ) - } - - @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) - private fun buildPromptDataWithCipher( - cipher: Cipher, - ): BiometricPromptData = BiometricPromptData.Builder() - .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) - .setCryptoObject(BiometricPrompt.CryptoObject(cipher)) - .build() - - /** - * Converts a network icon to an [IconCompat]. Performs a blocking network request to fetch the - * icon, so only call this method from a background thread or coroutine. - */ - @OmitFromCoverage - @WorkerThread - private suspend fun IconData.Network.toIconCompat(): IconCompat = try { - val futureTargetBitmap = Glide - .with(context) - .asBitmap() - .load(this.uri) - .placeholder(R.drawable.ic_bw_passkey) - .submit() - - IconCompat.createWithBitmap(futureTargetBitmap.get()) - } catch (_: ExecutionException) { - null - } catch (_: InterruptedException) { - null - } - ?: IconCompat.createWithResource( - context, - this.fallbackIconRes, - ) - private suspend fun registerFido2CredentialForUnprivilegedApp( userId: String, callingAppInfo: CallingAppInfo, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/credentials/model/GetCredentialsRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/credentials/model/GetCredentialsRequest.kt index bf13e98f32..2df9df844b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/credentials/model/GetCredentialsRequest.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/credentials/model/GetCredentialsRequest.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.credentials.model import android.os.Bundle import android.os.Parcelable import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetPasswordOption import androidx.credentials.provider.BeginGetPublicKeyCredentialOption import androidx.credentials.provider.CallingAppInfo import kotlinx.parcelize.IgnoredOnParcel @@ -30,7 +31,7 @@ data class GetCredentialsRequest( /** * The [BeginGetPublicKeyCredentialOption]s of the [providerRequest], or an empty list if no - * public key credentials are present. + * public key options are present. */ @IgnoredOnParcel val beginGetPublicKeyCredentialOptions: List by lazy { @@ -40,6 +41,18 @@ data class GetCredentialsRequest( .orEmpty() } + /** + * The [BeginGetPasswordOption]s of the [providerRequest], or an empty list if no password + * options are present. + */ + @IgnoredOnParcel + val beginGetPasswordOptions: List by lazy { + providerRequest + ?.beginGetCredentialOptions + ?.filterIsInstance() + .orEmpty() + } + /** * The [CallingAppInfo] of the [providerRequest], or null if the [providerRequest] is not a * [BeginGetCredentialRequest]. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/credentials/model/ProviderGetPasswordCredentialRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/credentials/model/ProviderGetPasswordCredentialRequest.kt new file mode 100644 index 0000000000..a9a04e3121 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/credentials/model/ProviderGetPasswordCredentialRequest.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.data.credentials.model + +import android.os.Bundle +import android.os.Parcelable +import androidx.credentials.provider.ProviderGetCredentialRequest +import kotlinx.parcelize.Parcelize + +/** + * A wrapper around [ProviderGetCredentialRequest] that includes additional information needed to + * fulfill the request. + * + * @param userId The ID of the user that owns the credential being requested. + * @param cipherId The ID of the cipher containing the password to be retrieved. + * @param isUserVerified Whether the user has been verified prior to this request. + * @param requestData The original request data from the system. + */ +@Parcelize +data class ProviderGetPasswordCredentialRequest( + val userId: String, + val cipherId: String, + val isUserVerified: Boolean, + val requestData: Bundle, +) : Parcelable diff --git a/app/src/main/java/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorImpl.kt index 4d8b47c655..c0de02c300 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorImpl.kt @@ -22,16 +22,15 @@ import androidx.credentials.provider.BeginCreateCredentialResponse import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetCredentialResponse -import androidx.credentials.provider.BeginGetPublicKeyCredentialOption import androidx.credentials.provider.BiometricPromptData import androidx.credentials.provider.CreateEntry -import androidx.credentials.provider.CredentialEntry import androidx.credentials.provider.ProviderClearCredentialStateRequest import com.bitwarden.data.manager.DispatcherManager import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager +import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.model.FlagKey @@ -124,15 +123,14 @@ class CredentialProviderProcessorImpl( // Otherwise, find all matching credentials from the current vault. val getCredentialJob = ioScope.launch { - getMatchingFido2CredentialEntries( - userId = userState.activeUserId, - request = request, - ) - .onSuccess { - callback.onResult( - BeginGetCredentialResponse(credentialEntries = it), - ) - } + bitwardenCredentialManager + .getCredentialEntries( + getCredentialsRequest = GetCredentialsRequest( + userId = userState.activeUserId, + BeginGetCredentialRequest.asBundle(request), + ), + ) + .onSuccess { callback.onResult(BeginGetCredentialResponse(credentialEntries = it)) } .onFailure { callback.onError(GetCredentialUnknownException(it.message)) } } cancellationSignal.setOnCancelListener { @@ -213,18 +211,6 @@ class CredentialProviderProcessorImpl( return entryBuilder.build() } - private suspend fun getMatchingFido2CredentialEntries( - userId: String, - request: BeginGetCredentialRequest, - ): Result> = - bitwardenCredentialManager - .getCredentialEntries( - userId = userId, - options = request - .beginGetCredentialOptions - .filterIsInstance(), - ) - private fun CreateEntry.Builder.setBiometricPromptDataIfSupported( cipher: Cipher, ): CreateEntry.Builder { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/credentials/util/BiometricPromptDataUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/credentials/util/BiometricPromptDataUtils.kt new file mode 100644 index 0000000000..99c4fe1bd7 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/credentials/util/BiometricPromptDataUtils.kt @@ -0,0 +1,22 @@ +@file:OmitFromCoverage + +package com.x8bit.bitwarden.data.credentials.util + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.credentials.provider.BiometricPromptData +import com.bitwarden.core.annotation.OmitFromCoverage +import javax.crypto.Cipher + +/** + * Builds a [BiometricPromptData] instance with the provided [Cipher]. + */ +@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) +fun buildPromptDataWithCipher( + cipher: Cipher, +): BiometricPromptData = BiometricPromptData.Builder() + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .setCryptoObject(BiometricPrompt.CryptoObject(cipher)) + .build() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/credentials/util/PublicKeyCredentialEntryBuilderExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/credentials/util/PublicKeyCredentialEntryBuilderExtensions.kt new file mode 100644 index 0000000000..823e37ba6f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/credentials/util/PublicKeyCredentialEntryBuilderExtensions.kt @@ -0,0 +1,27 @@ +@file:OmitFromCoverage + +package com.x8bit.bitwarden.data.credentials.util + +import android.os.Build +import androidx.credentials.provider.PublicKeyCredentialEntry +import com.bitwarden.core.annotation.OmitFromCoverage +import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow +import javax.crypto.Cipher + +/** + * Sets the biometric prompt data on the [PublicKeyCredentialEntry.Builder] if supported. + */ +fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported( + cipher: Cipher?, + isSingleTapAuthEnabled: Boolean, +): PublicKeyCredentialEntry.Builder = + if (!isBuildVersionBelow(Build.VERSION_CODES.VANILLA_ICE_CREAM) && + cipher != null && + isSingleTapAuthEnabled + ) { + setBiometricPromptData( + biometricPromptData = buildPromptDataWithCipher(cipher), + ) + } else { + this + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index a50e286369..ceb36763ce 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting import android.os.Parcelable import androidx.annotation.DrawableRes import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.provider.CredentialEntry import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.credentials.provider.ProviderGetCredentialRequest import androidx.lifecycle.SavedStateHandle @@ -1253,6 +1254,10 @@ class VaultItemListingViewModel @Inject constructor( VaultItemListingsAction.Internal.InternetConnectionErrorReceived -> { handleInternetConnectionErrorReceived() } + + is VaultItemListingsAction.Internal.GetCredentialEntriesResultReceive -> { + handleGetCredentialEntriesResultReceive(action) + } } } @@ -1690,14 +1695,6 @@ class VaultItemListingViewModel @Inject constructor( private fun handleProviderGetCredentialsRequest( request: GetCredentialsRequest, ) { - val beginGetCredentialOption = request - .beginGetPublicKeyCredentialOptions - .ifEmpty { - showCredentialManagerErrorDialog( - R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), - ) - return - } val callingAppInfo = request.callingAppInfo ?: run { showCredentialManagerErrorDialog( @@ -1712,17 +1709,11 @@ class VaultItemListingViewModel @Inject constructor( ) when (validateOriginResult) { is ValidateOriginResult.Success -> { - sendEvent( - VaultItemListingEvent.CompleteProviderGetCredentialsRequest( - GetCredentialsResult.Success( - credentialEntries = bitwardenCredentialManager - .getCredentialEntries( - userId = request.userId, - options = beginGetCredentialOption, - ) - .getOrNull() - .orEmpty(), - userId = request.userId, + sendAction( + VaultItemListingsAction.Internal.GetCredentialEntriesResultReceive( + userId = request.userId, + result = bitwardenCredentialManager.getCredentialEntries( + getCredentialsRequest = request, ), ), ) @@ -1861,6 +1852,27 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun handleGetCredentialEntriesResultReceive( + action: VaultItemListingsAction.Internal.GetCredentialEntriesResultReceive, + ) { + action.result + .onFailure { + showCredentialManagerErrorDialog( + message = R.string.generic_error_message.asText(), + ) + } + .onSuccess { credentialEntries -> + sendEvent( + VaultItemListingEvent.CompleteProviderGetCredentialsRequest( + GetCredentialsResult.Success( + credentialEntries = credentialEntries, + userId = action.userId, + ), + ), + ) + } + } + private fun updateStateWithVaultData(vaultData: VaultData, clearDialogState: Boolean) { mutableStateFlow.update { currentState -> currentState.copy( @@ -2916,6 +2928,14 @@ sealed class VaultItemListingsAction { * Indicates that the there is not internet connection. */ data object InternetConnectionErrorReceived : Internal() + + /** + * Indicates that a result for building credential entries has been received. + */ + data class GetCredentialEntriesResultReceive( + val userId: String, + val result: Result>, + ) : Internal() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilderTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilderTest.kt new file mode 100644 index 0000000000..8682707509 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/credentials/builder/CredentialEntryBuilderTest.kt @@ -0,0 +1,243 @@ +package com.x8bit.bitwarden.data.credentials.builder + +import android.app.PendingIntent +import android.content.Context +import android.graphics.drawable.Icon +import androidx.core.graphics.drawable.IconCompat +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.PublicKeyCredentialEntry +import com.bitwarden.fido.Fido2CredentialAutofillView +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow +import com.x8bit.bitwarden.data.util.mockBuilder +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFido2CredentialAutofillView +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.unmockkConstructor +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class CredentialEntryBuilderTest { + + private val mockContext = mockk() + private val mockGetPublicKeyCredentialIntent = mockk(relaxed = true) + private val mockIntentManager = mockk { + every { + createFido2GetCredentialPendingIntent( + action = any(), + userId = any(), + cipherId = any(), + credentialId = any(), + requestCode = any(), + isUserVerified = any(), + ) + } returns mockGetPublicKeyCredentialIntent + } + private val mockFeatureFlagManager = mockk() + private val mockBiometricsEncryptionManager = mockk() + private val mockBeginGetPublicKeyOption = mockk() + private val credentialEntryBuilder = CredentialEntryBuilderImpl( + context = mockContext, + intentManager = mockIntentManager, + featureFlagManager = mockFeatureFlagManager, + biometricsEncryptionManager = mockBiometricsEncryptionManager, + ) + private val mockPublicKeyCredentialEntry = mockk(relaxed = true) + private val mockIcon = mockk() + + @BeforeEach + @Test + fun setUp() { + mockkConstructor(PublicKeyCredentialEntry.Builder::class) + mockkStatic(IconCompat::class) + mockBuilder { it.setIcon(any()) } + every { IconCompat.createWithResource(any(), any()) } returns mockk { + every { toIcon(mockContext) } returns mockIcon + } + every { + anyConstructed().build() + } returns mockPublicKeyCredentialEntry + } + + @AfterEach + @Test + fun tearDown() { + unmockkStatic(IconCompat::class) + unmockkStatic(::isBuildVersionBelow) + unmockkConstructor(PublicKeyCredentialEntry.Builder::class) + } + + @Suppress("MaxLineLength") + @Test + fun `buildPublicKeyCredentialEntries should return Success with empty list when options list is empty`() = + runTest { + val options = emptyList() + val fido2AutofillViews: List = listOf( + createMockFido2CredentialAutofillView(number = 1), + ) + + val result = credentialEntryBuilder + .buildPublicKeyCredentialEntries( + userId = "userId", + isUserVerified = false, + fido2CredentialAutofillViews = fido2AutofillViews, + beginGetPublicKeyCredentialOptions = options, + ) + assertTrue(result.isEmpty()) + } + + @Suppress("MaxLineLength") + @Test + fun `buildPublicKeyCredentialEntries should return Success with empty list when fido2AutofillViews is empty`() = + runTest { + val options = listOf(mockBeginGetPublicKeyOption) + val fido2AutofillViews = emptyList() + val result = credentialEntryBuilder + .buildPublicKeyCredentialEntries( + userId = "userId", + isUserVerified = false, + fido2CredentialAutofillViews = fido2AutofillViews, + beginGetPublicKeyCredentialOptions = options, + ) + assertTrue(result.isEmpty()) + } + + @Suppress("MaxLineLength") + @Test + fun `buildPublicKeyCredentialEntries should return Success with list of PublicKeyCredentialEntry`() = + runTest { + val options = listOf(mockBeginGetPublicKeyOption) + val fido2AutofillViews: List = listOf( + createMockFido2CredentialAutofillView(number = 1), + ) + + every { + mockFeatureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication) + } returns false + every { + mockBiometricsEncryptionManager.getOrCreateCipher("userId") + } returns null + + val result = credentialEntryBuilder + .buildPublicKeyCredentialEntries( + userId = "userId", + isUserVerified = false, + fido2CredentialAutofillViews = fido2AutofillViews, + beginGetPublicKeyCredentialOptions = options, + ) + + assertTrue(result.isNotEmpty()) + + verify { + mockIntentManager.createFido2GetCredentialPendingIntent( + action = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY", + userId = "userId", + cipherId = "mockCipherId-1", + credentialId = fido2AutofillViews.first().credentialId.toString(), + requestCode = any(), + isUserVerified = false, + ) + + anyConstructed().setIcon(mockIcon) + } + } + + @Test + fun `buildPublicKeyCredentialEntries should set biometric prompt data correctly`() = runTest { + mockkStatic(::isBuildVersionBelow) + val options = listOf(mockBeginGetPublicKeyOption) + val fido2AutofillViews: List = listOf( + createMockFido2CredentialAutofillView(number = 1), + ) + + // Verify biometric prompt data is not set when flag is false, buildVersion is < 35, and + // cipher is null. + every { + mockFeatureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication) + } returns false + every { + mockBiometricsEncryptionManager.getOrCreateCipher("userId") + } returns null + every { isBuildVersionBelow(any()) } returns false + + credentialEntryBuilder + .buildPublicKeyCredentialEntries( + userId = "userId", + isUserVerified = false, + fido2CredentialAutofillViews = fido2AutofillViews, + beginGetPublicKeyCredentialOptions = options, + ) + verify(exactly = 0) { + anyConstructed().setBiometricPromptData(any()) + } + + // Verify biometric prompt data is not set when flag is true, buildVersion is < 35, and + // cipher is null. + every { + mockFeatureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication) + } returns true + credentialEntryBuilder + .buildPublicKeyCredentialEntries( + userId = "userId", + isUserVerified = false, + fido2CredentialAutofillViews = fido2AutofillViews, + beginGetPublicKeyCredentialOptions = options, + ) + + verify(exactly = 0) { + anyConstructed().setBiometricPromptData(any()) + } + + // Verify biometric prompt data is not set when flag is true, buildVersion is >= 35, and + // cipher is null + every { isBuildVersionBelow(any()) } returns false + credentialEntryBuilder + .buildPublicKeyCredentialEntries( + userId = "userId", + isUserVerified = false, + fido2CredentialAutofillViews = fido2AutofillViews, + beginGetPublicKeyCredentialOptions = options, + ) + verify(exactly = 0) { + anyConstructed().setBiometricPromptData(any()) + } + + // Verify biometric prompt data is not set when user is verified + every { + mockBiometricsEncryptionManager.getOrCreateCipher(any()) + } returns mockk(relaxed = true) + credentialEntryBuilder + .buildPublicKeyCredentialEntries( + userId = "userId", + isUserVerified = true, + fido2CredentialAutofillViews = fido2AutofillViews, + beginGetPublicKeyCredentialOptions = options, + ) + verify(exactly = 0) { + anyConstructed().setBiometricPromptData(any()) + } + + // Verify biometric prompt data is set when flag is true, buildVersion is >= 35, cipher is + // not null, and user is not verified + credentialEntryBuilder + .buildPublicKeyCredentialEntries( + userId = "userId", + isUserVerified = false, + fido2CredentialAutofillViews = fido2AutofillViews, + beginGetPublicKeyCredentialOptions = options, + ) + verify(exactly = 1) { + anyConstructed().setBiometricPromptData(any()) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManagerTest.kt index 5dac6fef24..4bc67a3e43 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/credentials/manager/BitwardenCredentialManagerTest.kt @@ -1,9 +1,7 @@ package com.x8bit.bitwarden.data.credentials.manager -import android.content.Context import android.content.pm.Signature import android.content.pm.SigningInfo -import android.graphics.drawable.Icon import android.net.Uri import android.util.Base64 import androidx.core.graphics.drawable.IconCompat @@ -25,20 +23,18 @@ import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse import com.bitwarden.fido.UnverifiedAssetLink import com.bitwarden.sdk.BitwardenException import com.bitwarden.sdk.Fido2CredentialStore +import com.bitwarden.vault.CipherView +import com.x8bit.bitwarden.data.credentials.builder.CredentialEntryBuilder import com.x8bit.bitwarden.data.credentials.model.Fido2AttestationResponse import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionResult import com.x8bit.bitwarden.data.credentials.model.Fido2PublicKeyCredential import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult +import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest import com.x8bit.bitwarden.data.credentials.model.PasskeyAssertionOptions import com.x8bit.bitwarden.data.credentials.model.PasskeyAttestationOptions import com.x8bit.bitwarden.data.credentials.model.UserVerificationRequirement -import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager -import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager -import com.x8bit.bitwarden.data.platform.manager.model.FlagKey -import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow -import com.x8bit.bitwarden.data.util.mockBuilder import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest @@ -51,14 +47,12 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFido2Cre import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidFido2PublicKeyCredential import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult -import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAssertionOptions import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAttestationOptions import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.mockkConstructor import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkConstructor @@ -82,6 +76,9 @@ class BitwardenCredentialManagerTest { private lateinit var bitwardenCredentialManager: BitwardenCredentialManager + private val mutableCipherStateFlow = + MutableStateFlow>>(DataState.Loading) + private val json = mockk { every { decodeFromStringOrNull(any()) @@ -122,11 +119,10 @@ class BitwardenCredentialManagerTest { } private val mockVaultSdkSource = mockk() private val mockFido2CredentialStore = mockk() - private val mockIntentManager = mockk() - private val mockVaultRepository = mockk() - private val mockFeatureFlagManager = mockk() - private val mockBiometricsEncryptionManager = mockk() - private val fakeEnvironmentRepository = FakeEnvironmentRepository() + private val mockVaultRepository = mockk { + every { ciphersStateFlow } returns mutableCipherStateFlow + } + private val mockCredentialEntryBuilder = mockk() @BeforeEach fun setUp() { @@ -138,16 +134,12 @@ class BitwardenCredentialManagerTest { every { Base64.encodeToString(any(), any()) } returns DEFAULT_APP_SIGNATURE bitwardenCredentialManager = BitwardenCredentialManagerImpl( - context = mockk(relaxed = true), vaultSdkSource = mockVaultSdkSource, fido2CredentialStore = mockFido2CredentialStore, json = json, - intentManager = mockIntentManager, dispatcherManager = FakeDispatcherManager(), vaultRepository = mockVaultRepository, - featureFlagManager = mockFeatureFlagManager, - biometricsEncryptionManager = mockBiometricsEncryptionManager, - environmentRepository = fakeEnvironmentRepository, + credentialEntryBuilder = mockCredentialEntryBuilder, ) } @@ -879,65 +871,75 @@ class BitwardenCredentialManagerTest { ) } + @Suppress("MaxLineLength") @Test - fun `getCredentialEntries should return empty list when cipherViews are empty`() = + fun `getCredentialEntries with public key credential options should return empty list when no ciphers have FIDO 2 credentials`() = runTest { val mockBeginGetPublicKeyCredentialOption = mockk() + val mockGetCredentialsRequest = mockk { + every { callingAppInfo } returns mockCallingAppInfo + every { + beginGetPublicKeyCredentialOptions + } returns listOf(mockBeginGetPublicKeyCredentialOption) + } every { mockBeginGetPublicKeyCredentialOption.requestJson } returns DEFAULT_FIDO2_AUTH_REQUEST_JSON - every { - mockVaultRepository.ciphersStateFlow - } returns MutableStateFlow(DataState.Loaded(emptyList())) - val result = bitwardenCredentialManager.getCredentialEntries( - userId = "mockUserId", - options = listOf(mockBeginGetPublicKeyCredentialOption), - ) + mutableCipherStateFlow.value = DataState.Loaded(emptyList()) + val result = bitwardenCredentialManager.getCredentialEntries(mockGetCredentialsRequest) assertEquals(emptyList(), result.getOrNull()) } @Suppress("MaxLineLength") @Test - fun `getCredentialEntries should return error when FIDO 2 credential decryption fails`() = runTest { - val mockBeginGetPublicKeyCredentialOption = mockk() - every { - mockBeginGetPublicKeyCredentialOption.requestJson - } returns DEFAULT_FIDO2_AUTH_REQUEST_JSON - every { - mockVaultRepository.ciphersStateFlow - } returns MutableStateFlow( - DataState.Loaded( + fun `getCredentialEntries with public key credential options should return error when FIDO 2 credential decryption fails`() = + runTest { + val mockBeginGetPublicKeyCredentialOption = mockk() + val mockGetCredentialsRequest = mockk { + every { callingAppInfo } returns mockCallingAppInfo + every { + beginGetPublicKeyCredentialOptions + } returns listOf(mockBeginGetPublicKeyCredentialOption) + every { beginGetPasswordOptions } returns emptyList() + every { userId } returns "mockUserId" + } + every { + mockBeginGetPublicKeyCredentialOption.requestJson + } returns DEFAULT_FIDO2_AUTH_REQUEST_JSON + mutableCipherStateFlow.value = DataState.Loaded( listOf( createMockCipherView( number = 1, fido2Credentials = createMockSdkFido2CredentialList(number = 1), ), ), - ), - ) - coEvery { - mockVaultRepository.getDecryptedFido2CredentialAutofillViews(any()) - } returns DecryptFido2CredentialAutofillViewResult.Error( - BitwardenException.E("Error decrypting credentials."), - ) - val result = bitwardenCredentialManager.getCredentialEntries( - userId = "mockUserId", - options = listOf(mockBeginGetPublicKeyCredentialOption), - ) - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is GetCredentialUnknownException) - } + ) + coEvery { + mockVaultRepository.getDecryptedFido2CredentialAutofillViews(any()) + } returns DecryptFido2CredentialAutofillViewResult.Error( + BitwardenException.E("Error decrypting credentials."), + ) + val result = bitwardenCredentialManager.getCredentialEntries(mockGetCredentialsRequest) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is GetCredentialUnknownException) + } + @Suppress("MaxLineLength") @Test - fun `getCredentialEntries should return error when passkey assertion options are null`() = + fun `getCredentialEntries with public key credential options should return error when passkey assertion options are null`() = runTest { - val mockRequest = mockk { + val mockOption = mockk { every { requestJson } returns "" } - every { - mockVaultRepository.ciphersStateFlow - } returns MutableStateFlow( - DataState.Loaded( + val mockGetCredentialsRequest = mockk { + every { callingAppInfo } returns mockCallingAppInfo + every { + beginGetPublicKeyCredentialOptions + } returns listOf(mockOption) + every { beginGetPasswordOptions } returns emptyList() + every { userId } returns "mockUserId" + } + mutableCipherStateFlow.value = DataState.Loaded( listOf( createMockCipherView( number = 1, @@ -945,7 +947,6 @@ class BitwardenCredentialManagerTest { fido2Credentials = createMockSdkFido2CredentialList(number = 1), ), ), - ), ) coEvery { mockVaultRepository.getDecryptedFido2CredentialAutofillViews(any()) @@ -961,25 +962,28 @@ class BitwardenCredentialManagerTest { every { json.decodeFromStringOrNull(any()) } returns null - val result = bitwardenCredentialManager.getCredentialEntries( - userId = "mockUserId", - options = listOf(mockRequest), - ) + val result = bitwardenCredentialManager.getCredentialEntries(mockGetCredentialsRequest) assertTrue( result.exceptionOrNull() is GetCredentialUnknownException, ) } + @Suppress("MaxLineLength") @Test - fun `getCredentialEntries should return error when FIDO 2 relyingPartyId is null`() = + fun `getCredentialEntries with public key credential options should return error when FIDO 2 relyingPartyId is null`() = runTest { - val mockRequest = mockk { + val mockOption = mockk { every { requestJson } returns "" } - every { - mockVaultRepository.ciphersStateFlow - } returns MutableStateFlow( - DataState.Loaded( + val mockGetCredentialsRequest = mockk { + every { callingAppInfo } returns mockCallingAppInfo + every { + beginGetPublicKeyCredentialOptions + } returns listOf(mockOption) + every { beginGetPasswordOptions } returns emptyList() + every { userId } returns "mockUserId" + } + mutableCipherStateFlow.value = DataState.Loaded( listOf( createMockCipherView( number = 1, @@ -987,7 +991,6 @@ class BitwardenCredentialManagerTest { fido2Credentials = createMockSdkFido2CredentialList(number = 1), ), ), - ), ) coEvery { mockVaultRepository.getDecryptedFido2CredentialAutofillViews(any()) @@ -1003,10 +1006,8 @@ class BitwardenCredentialManagerTest { every { json.decodeFromStringOrNull(any()) } returns createMockPasskeyAssertionOptions(number = 1, relyingPartyId = null) - val result = bitwardenCredentialManager.getCredentialEntries( - userId = "mockUserId", - options = listOf(mockRequest), - ) + val result = bitwardenCredentialManager + .getCredentialEntries(mockGetCredentialsRequest) assertTrue( result.exceptionOrNull() is GetCredentialUnknownException, ) @@ -1014,116 +1015,56 @@ class BitwardenCredentialManagerTest { @Suppress("MaxLineLength") @Test - fun `getCredentialEntries should return list of PublicKeyCredentialEntry when FIDO 2 credential decryption succeeds`() = + fun `getCredentialEntries should build public key credential entries when decryption succeeds`() = runTest { - setupMockUri() - mockkStatic(IconCompat::class) - mockkStatic(::isBuildVersionBelow) - mockkConstructor(PublicKeyCredentialEntry.Builder::class) - every { - anyConstructed().build() - } returns mockk() val mockBeginGetPublicKeyCredentialOption = mockk() + val mockGetCredentialsRequest = mockk { + every { callingAppInfo } returns mockCallingAppInfo + every { + beginGetPublicKeyCredentialOptions + } returns listOf(mockBeginGetPublicKeyCredentialOption) + every { userId } returns "mockUserId" + } + val fido2CredentialAutofillViews = listOf( + createMockFido2CredentialAutofillView( + number = 1, + cipherId = "mockId-1", + rpId = "mockRelyingPartyId-1", + ), + ) every { mockBeginGetPublicKeyCredentialOption.requestJson } returns DEFAULT_FIDO2_AUTH_REQUEST_JSON - every { - mockVaultRepository.ciphersStateFlow - } returns MutableStateFlow( - DataState.Loaded( - listOf( - createMockCipherView( - number = 1, - login = createMockLoginView(number = 1, hasUris = false), - fido2Credentials = createMockSdkFido2CredentialList(number = 1), - ), + mutableCipherStateFlow.value = DataState.Loaded( + listOf( + createMockCipherView( + number = 1, + login = createMockLoginView(number = 1, hasUris = false), + fido2Credentials = createMockSdkFido2CredentialList(number = 1), ), ), ) coEvery { mockVaultRepository.getDecryptedFido2CredentialAutofillViews(any()) } returns DecryptFido2CredentialAutofillViewResult.Success( - listOf( - createMockFido2CredentialAutofillView( - number = 1, - cipherId = "mockId-1", - rpId = "mockRelyingPartyId-1", - ), - ), + fido2CredentialAutofillViews, ) - val mockIcon = mockk() every { - IconCompat.createWithResource(any(), any()) - } returns mockk { - every { toIcon(any()) } returns mockIcon - } - every { - mockIntentManager.createFido2GetCredentialPendingIntent( - action = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY", + mockCredentialEntryBuilder.buildPublicKeyCredentialEntries( userId = "mockUserId", - credentialId = any(), - cipherId = "mockId-1", + fido2CredentialAutofillViews = fido2CredentialAutofillViews, + beginGetPublicKeyCredentialOptions = listOf( + mockBeginGetPublicKeyCredentialOption, + ), isUserVerified = false, - requestCode = any(), ) - } returns mockk() - every { - mockBiometricsEncryptionManager.getOrCreateCipher("mockUserId") - } returns mockk() - every { - mockFeatureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication) - } returns false - mockBuilder { it.setIcon(mockIcon) } - mockBuilder { it.setBiometricPromptData(any()) } - val result = bitwardenCredentialManager.getCredentialEntries( - userId = "mockUserId", - options = listOf(mockBeginGetPublicKeyCredentialOption), - ) + } returns listOf(mockk()) + + val result = bitwardenCredentialManager.getCredentialEntries(mockGetCredentialsRequest) assertTrue(result.isSuccess) assertEquals(1, result.getOrNull()?.size) assertTrue(result.getOrNull()?.first() is PublicKeyCredentialEntry) - verify { - anyConstructed().setIcon(mockIcon) - } - verify(exactly = 0) { - anyConstructed().setBiometricPromptData(any()) - } - - // Verify biometric prompt data IS NOT set when single tap feature flag is enabled and - // build version is < 35 - every { - mockFeatureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication) - } returns true - every { isBuildVersionBelow(35) } returns true - bitwardenCredentialManager.getCredentialEntries( - userId = "mockUserId", - options = listOf(mockBeginGetPublicKeyCredentialOption), - ) - verify(exactly = 0) { - anyConstructed().setBiometricPromptData(any()) - } - - // Verify biometric prompt data IS set when single tap feature flag is enabled and build - // version is 35+ - every { - mockFeatureFlagManager.getFeatureFlag(FlagKey.SingleTapPasskeyAuthentication) - } returns true - every { isBuildVersionBelow(35) } returns false - bitwardenCredentialManager.getCredentialEntries( - userId = "mockUserId", - options = listOf(mockBeginGetPublicKeyCredentialOption), - ) - verify { - anyConstructed().setBiometricPromptData(any()) - } } - - private fun setupMockUri() { - mockkStatic(Uri::class) - val uriMock = mockk() - every { Uri.parse(any()) } returns uriMock - every { uriMock.host } returns "www.mockuri.com" - } } private const val DEFAULT_PACKAGE_NAME = "com.x8bit.bitwarden" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorTest.kt index 0c66c8f0dd..271a31f9dd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/credentials/processor/CredentialProviderProcessorTest.kt @@ -69,9 +69,7 @@ class CredentialProviderProcessorTest { } private val credentialEntries = listOf(mockk(relaxed = true)) private val bitwardenCredentialManager: BitwardenCredentialManager = mockk { - coEvery { - getCredentialEntries(any(), any()) - } returns credentialEntries.asSuccess() + coEvery { getCredentialEntries(any()) } returns credentialEntries.asSuccess() } private val intentManager: IntentManager = mockk() private val dispatcherManager: DispatcherManager = FakeDispatcherManager() @@ -459,10 +457,7 @@ class CredentialProviderProcessorTest { every { cancellationSignal.setOnCancelListener(any()) } just runs every { callback.onError(capture(captureSlot)) } just runs coEvery { - bitwardenCredentialManager.getCredentialEntries( - userId = DEFAULT_USER_STATE.activeUserId, - options = listOf(mockOption), - ) + bitwardenCredentialManager.getCredentialEntries(any()) } returns Result.failure(Exception("Error decrypting credentials.")) credentialProviderProcessor.processGetCredentialRequest( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt index 570a3e45f8..8fc012d2dd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -95,11 +95,13 @@ fun createMockCipherView( /** * Create a mock [LoginView] with a given [number]. */ +@Suppress("LongParameterList") fun createMockLoginView( number: Int, totp: String? = "mockTotp-$number", clock: Clock = FIXED_CLOCK, hasUris: Boolean = true, + uris: List? = listOf(createMockUriView(number = number)), fido2Credentials: List? = createMockSdkFido2CredentialList(number, clock), ): LoginView = LoginView( @@ -107,7 +109,7 @@ fun createMockLoginView( password = "mockPassword-$number", passwordRevisionDate = clock.instant(), autofillOnPageLoad = false, - uris = listOf(createMockUriView(number = number)).takeIf { hasUris }, + uris = uris.takeIf { hasUris }, totp = totp, fido2Credentials = fido2Credentials, ) @@ -162,9 +164,9 @@ fun createMockFido2CredentialAutofillView( /** * Create a mock [LoginUriView] with a given [number]. */ -fun createMockUriView(number: Int): LoginUriView = +fun createMockUriView(number: Int, uri: String = "www.mockuri$number.com"): LoginUriView = LoginUriView( - uri = "www.mockuri$number.com", + uri = uri, match = UriMatchType.HOST, uriChecksum = "mockUriChecksum-$number", ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 9d29e80636..80d6d3ac12 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -4,6 +4,7 @@ import android.net.Uri import androidx.core.os.bundleOf import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.BeginGetCredentialRequest import androidx.credentials.provider.BeginGetPublicKeyCredentialOption import androidx.credentials.provider.ProviderCreateCredentialRequest @@ -12,6 +13,7 @@ import androidx.credentials.provider.PublicKeyCredentialEntry import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.data.repository.model.DataState +import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager import com.bitwarden.data.repository.model.Environment @@ -2967,10 +2969,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { mockGetCredentialsRequest, ) coEvery { - bitwardenCredentialManager.getCredentialEntries( - userId = "mockUserId-1", - options = listOf(mockBeginGetPublicKeyCredentialOption), - ) + bitwardenCredentialManager.getCredentialEntries(any()) } returns emptyList().asSuccess() coEvery { originManager.validateOrigin(callingAppInfo = any()) @@ -3007,8 +3006,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `GetCredentialsRequest should display error dialog when getCredentialEntries is failure`() = + fun `GetCredentialsRequest should emit GetCredentialEntriesResultReceive when result is received`() = runTest { setupMockUri() val mockGetCredentialsRequest = createMockGetCredentialsRequest(number = 1) @@ -3031,6 +3031,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { mockBeginGetCredentialRequest.beginGetCredentialOptions } returns emptyList() + coEvery { + bitwardenCredentialManager.getCredentialEntries(any()) + } returns GetCredentialUnknownException("Internal error").asFailure() val dataState = DataState.Loaded( data = VaultData( @@ -3047,8 +3050,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { assertEquals( VaultItemListingState.DialogState.CredentialManagerOperationFail( title = R.string.an_error_has_occurred.asText(), - message = - R.string.passkey_operation_failed_because_the_request_is_invalid.asText(), + message = R.string.generic_error_message.asText(), ), viewModel.stateFlow.value.dialogState, )