[PM-21567] Implement CredentialEntryBuilder interface (#5177)

This commit is contained in:
Patrick Honkonen 2025-05-13 17:18:16 -04:00 committed by GitHub
parent 860a2e265f
commit 7f4e65d7e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 671 additions and 437 deletions

View File

@ -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<Fido2CredentialAutofillView>,
beginGetPublicKeyCredentialOptions: List<BeginGetPublicKeyCredentialOption>,
isUserVerified: Boolean,
): List<PublicKeyCredentialEntry>
}

View File

@ -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<Fido2CredentialAutofillView>,
beginGetPublicKeyCredentialOptions: List<BeginGetPublicKeyCredentialOption>,
isUserVerified: Boolean,
): List<PublicKeyCredentialEntry> = beginGetPublicKeyCredentialOptions
.flatMap { option ->
fido2CredentialAutofillViews
.toPublicKeyCredentialEntryList(
userId = userId,
option = option,
isUserVerified = isUserVerified,
)
}
private fun List<Fido2CredentialAutofillView>.toPublicKeyCredentialEntryList(
userId: String,
option: BeginGetPublicKeyCredentialOption,
isUserVerified: Boolean,
): List<PublicKeyCredentialEntry> = 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)
}

View File

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

View File

@ -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<BeginGetCredentialOption>,
getCredentialsRequest: GetCredentialsRequest,
): Result<List<CredentialEntry>>
}

View File

@ -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<BeginGetCredentialOption>,
getCredentialsRequest: GetCredentialsRequest,
): Result<List<CredentialEntry>> = withContext(ioScope.coroutineContext) {
val cipherViews = vaultRepository
.ciphersStateFlow
@ -209,33 +180,18 @@ class BitwardenCredentialManagerImpl(
else -> emptyList()
}
}
.filter { it.isActiveWithFido2Credentials }
.ifEmpty {
return@withContext emptyList<CredentialEntry>().asSuccess()
}
val publicKeyCredentialOptions = options
.filterIsInstance<BeginGetPublicKeyCredentialOption>()
.ifEmpty { return@withContext emptyList<CredentialEntry>().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<BeginGetPublicKeyCredentialOption>.toPublicKeyCredentialEntries(
userId: String,
cipherViews: List<CipherView>,
fido2CredentialAutofillViews: List<Fido2CredentialAutofillView>,
): Result<List<PublicKeyCredentialEntry>> {
val baseIconUrl = environmentRepository
.environment
.environmentUrlData
.baseIconUrl
cipherViewsWithPublicKeyCredentials: List<CipherView>,
): Result<List<CredentialEntry>> {
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<PublicKeyCredentialEntry>().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<CipherView, Fido2CredentialAutofillView>.toPublicKeyCredentialEntryList(
baseIconUrl: String,
userId: String,
option: BeginGetPublicKeyCredentialOption,
): List<PublicKeyCredentialEntry> = 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,

View File

@ -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<BeginGetPublicKeyCredentialOption> 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<BeginGetPasswordOption> by lazy {
providerRequest
?.beginGetCredentialOptions
?.filterIsInstance<BeginGetPasswordOption>()
.orEmpty()
}
/**
* The [CallingAppInfo] of the [providerRequest], or null if the [providerRequest] is not a
* [BeginGetCredentialRequest].

View File

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

View File

@ -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<List<CredentialEntry>> =
bitwardenCredentialManager
.getCredentialEntries(
userId = userId,
options = request
.beginGetCredentialOptions
.filterIsInstance<BeginGetPublicKeyCredentialOption>(),
)
private fun CreateEntry.Builder.setBiometricPromptDataIfSupported(
cipher: Cipher,
): CreateEntry.Builder {

View File

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

View File

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

View File

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.provider.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<List<CredentialEntry>>,
) : Internal()
}
}

View File

@ -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<Context>()
private val mockGetPublicKeyCredentialIntent = mockk<PendingIntent>(relaxed = true)
private val mockIntentManager = mockk<IntentManager> {
every {
createFido2GetCredentialPendingIntent(
action = any(),
userId = any(),
cipherId = any(),
credentialId = any(),
requestCode = any(),
isUserVerified = any(),
)
} returns mockGetPublicKeyCredentialIntent
}
private val mockFeatureFlagManager = mockk<FeatureFlagManager>()
private val mockBiometricsEncryptionManager = mockk<BiometricsEncryptionManager>()
private val mockBeginGetPublicKeyOption = mockk<BeginGetPublicKeyCredentialOption>()
private val credentialEntryBuilder = CredentialEntryBuilderImpl(
context = mockContext,
intentManager = mockIntentManager,
featureFlagManager = mockFeatureFlagManager,
biometricsEncryptionManager = mockBiometricsEncryptionManager,
)
private val mockPublicKeyCredentialEntry = mockk<PublicKeyCredentialEntry>(relaxed = true)
private val mockIcon = mockk<Icon>()
@BeforeEach
@Test
fun setUp() {
mockkConstructor(PublicKeyCredentialEntry.Builder::class)
mockkStatic(IconCompat::class)
mockBuilder<PublicKeyCredentialEntry.Builder> { it.setIcon(any()) }
every { IconCompat.createWithResource(any(), any()) } returns mockk {
every { toIcon(mockContext) } returns mockIcon
}
every {
anyConstructed<PublicKeyCredentialEntry.Builder>().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<BeginGetPublicKeyCredentialOption>()
val fido2AutofillViews: List<Fido2CredentialAutofillView> = 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<Fido2CredentialAutofillView>()
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<Fido2CredentialAutofillView> = 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<PublicKeyCredentialEntry.Builder>().setIcon(mockIcon)
}
}
@Test
fun `buildPublicKeyCredentialEntries should set biometric prompt data correctly`() = runTest {
mockkStatic(::isBuildVersionBelow)
val options = listOf(mockBeginGetPublicKeyOption)
val fido2AutofillViews: List<Fido2CredentialAutofillView> = 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<PublicKeyCredentialEntry.Builder>().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<PublicKeyCredentialEntry.Builder>().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<PublicKeyCredentialEntry.Builder>().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<PublicKeyCredentialEntry.Builder>().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<PublicKeyCredentialEntry.Builder>().setBiometricPromptData(any())
}
}
}

View File

@ -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<List<CipherView>>>(DataState.Loading)
private val json = mockk<Json> {
every {
decodeFromStringOrNull<PasskeyAttestationOptions>(any())
@ -122,11 +119,10 @@ class BitwardenCredentialManagerTest {
}
private val mockVaultSdkSource = mockk<VaultSdkSource>()
private val mockFido2CredentialStore = mockk<Fido2CredentialStore>()
private val mockIntentManager = mockk<IntentManager>()
private val mockVaultRepository = mockk<VaultRepository>()
private val mockFeatureFlagManager = mockk<FeatureFlagManager>()
private val mockBiometricsEncryptionManager = mockk<BiometricsEncryptionManager>()
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
private val mockVaultRepository = mockk<VaultRepository> {
every { ciphersStateFlow } returns mutableCipherStateFlow
}
private val mockCredentialEntryBuilder = mockk<CredentialEntryBuilder>()
@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<BeginGetPublicKeyCredentialOption>()
val mockGetCredentialsRequest = mockk<GetCredentialsRequest> {
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<CredentialEntry>(), result.getOrNull())
}
@Suppress("MaxLineLength")
@Test
fun `getCredentialEntries should return error when FIDO 2 credential decryption fails`() = runTest {
val mockBeginGetPublicKeyCredentialOption = mockk<BeginGetPublicKeyCredentialOption>()
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<BeginGetPublicKeyCredentialOption>()
val mockGetCredentialsRequest = mockk<GetCredentialsRequest> {
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<BeginGetPublicKeyCredentialOption> {
val mockOption = mockk<BeginGetPublicKeyCredentialOption> {
every { requestJson } returns ""
}
every {
mockVaultRepository.ciphersStateFlow
} returns MutableStateFlow(
DataState.Loaded(
val mockGetCredentialsRequest = mockk<GetCredentialsRequest> {
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<PasskeyAssertionOptions>(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<BeginGetPublicKeyCredentialOption> {
val mockOption = mockk<BeginGetPublicKeyCredentialOption> {
every { requestJson } returns ""
}
every {
mockVaultRepository.ciphersStateFlow
} returns MutableStateFlow(
DataState.Loaded(
val mockGetCredentialsRequest = mockk<GetCredentialsRequest> {
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<PasskeyAssertionOptions>(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<PublicKeyCredentialEntry.Builder>().build()
} returns mockk()
val mockBeginGetPublicKeyCredentialOption = mockk<BeginGetPublicKeyCredentialOption>()
val mockGetCredentialsRequest = mockk<GetCredentialsRequest> {
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<Icon>()
every {
IconCompat.createWithResource(any<Context>(), any<Int>())
} 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<PublicKeyCredentialEntry.Builder> { it.setIcon(mockIcon) }
mockBuilder<PublicKeyCredentialEntry.Builder> { it.setBiometricPromptData(any()) }
val result = bitwardenCredentialManager.getCredentialEntries(
userId = "mockUserId",
options = listOf(mockBeginGetPublicKeyCredentialOption),
)
} returns listOf(mockk<PublicKeyCredentialEntry>())
val result = bitwardenCredentialManager.getCredentialEntries(mockGetCredentialsRequest)
assertTrue(result.isSuccess)
assertEquals(1, result.getOrNull()?.size)
assertTrue(result.getOrNull()?.first() is PublicKeyCredentialEntry)
verify {
anyConstructed<PublicKeyCredentialEntry.Builder>().setIcon(mockIcon)
}
verify(exactly = 0) {
anyConstructed<PublicKeyCredentialEntry.Builder>().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<PublicKeyCredentialEntry.Builder>().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<PublicKeyCredentialEntry.Builder>().setBiometricPromptData(any())
}
}
private fun setupMockUri() {
mockkStatic(Uri::class)
val uriMock = mockk<Uri>()
every { Uri.parse(any()) } returns uriMock
every { uriMock.host } returns "www.mockuri.com"
}
}
private const val DEFAULT_PACKAGE_NAME = "com.x8bit.bitwarden"

View File

@ -69,9 +69,7 @@ class CredentialProviderProcessorTest {
}
private val credentialEntries = listOf(mockk<CredentialEntry>(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(

View File

@ -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<LoginUriView>? = listOf(createMockUriView(number = number)),
fido2Credentials: List<Fido2Credential>? = 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",
)

View File

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