Merge branch 'main' into pm-6702/registration-flows

# Conflicts:
#	app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt
This commit is contained in:
André Bispo 2024-07-23 10:38:55 +01:00
commit 3251d776a2
No known key found for this signature in database
GPG key ID: E5610EF043C76548
32 changed files with 1290 additions and 30 deletions

View file

@ -9,6 +9,8 @@
android:exported="true">
<intent-filter>
<action android:name="com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

View file

@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorI
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
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
import dagger.Module
import dagger.Provides
@ -35,12 +36,18 @@ object Fido2ProviderModule {
fun provideCredentialProviderProcessor(
@ApplicationContext context: Context,
authRepository: AuthRepository,
vaultRepository: VaultRepository,
fido2CredentialStore: Fido2CredentialStore,
fido2CredentialManager: Fido2CredentialManager,
dispatcherManager: DispatcherManager,
intentManager: IntentManager,
): Fido2ProviderProcessor =
Fido2ProviderProcessorImpl(
context,
authRepository,
vaultRepository,
fido2CredentialStore,
fido2CredentialManager,
intentManager,
dispatcherManager,
)

View file

@ -4,6 +4,7 @@ import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
/**
@ -36,6 +37,13 @@ interface Fido2CredentialManager {
requestJson: String,
): PasskeyAttestationOptions?
/**
* Attempt to extract FIDO 2 passkey assertion options from the system [requestJson], or null.
*/
fun getPasskeyAssertionOptionsOrNull(
requestJson: String,
): PasskeyAssertionOptions?
/**
* Register a new FIDO 2 credential to a users vault.
*/
@ -44,4 +52,9 @@ interface Fido2CredentialManager {
fido2CredentialRequest: Fido2CredentialRequest,
selectedCipherView: CipherView,
): Fido2RegisterCredentialResult
/**
* Whether or not the user has authentication attempts remaining.
*/
fun hasAuthenticationAttemptsRemaining(): Boolean
}

View file

@ -9,6 +9,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.Digita
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.asFailure
@ -106,6 +107,17 @@ class Fido2CredentialManagerImpl(
null
}
override fun getPasskeyAssertionOptionsOrNull(
requestJson: String,
): PasskeyAssertionOptions? =
try {
json.decodeFromString<PasskeyAssertionOptions>(requestJson)
} catch (e: SerializationException) {
null
} catch (e: IllegalArgumentException) {
null
}
private suspend fun validateCallingApplicationAssetLinks(
fido2CredentialRequest: Fido2CredentialRequest,
): Fido2ValidateOriginResult {
@ -206,4 +218,9 @@ class Fido2CredentialManagerImpl(
e.asFailure()
}
}
override fun hasAuthenticationAttemptsRemaining(): Boolean =
authenticationAttempts < MAX_AUTHENTICATION_ATTEMPTS
}
private const val MAX_AUTHENTICATION_ATTEMPTS = 5

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models the request options for a passkey request, based off the spec found at:
* https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options
*/
@Serializable
data class PasskeyAssertionOptions(
@SerialName("challenge") val challenge: String,
@SerialName("allowCredentials") val allowCredentials: List<PublicKeyCredentialDescriptor>?,
@SerialName("rpId") val relyingPartyId: String?,
@SerialName("userVerification") val userVerification: String?,
)

View file

@ -90,19 +90,6 @@ data class PasskeyAttestationOptions(
}
}
/**
* Represents details about a credential provided in the creation options.
*/
@Serializable
data class PublicKeyCredentialDescriptor(
@SerialName("type")
val type: String,
@SerialName("id")
val id: String,
@SerialName("transports")
val transports: List<String>,
)
/**
* Represents parameters for a credential in the creation options.
*/

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents details about a credential provided in the creation options.
*/
@Serializable
data class PublicKeyCredentialDescriptor(
@SerialName("type")
val type: String,
@SerialName("id")
val id: String,
@SerialName("transports")
val transports: List<String>,
)

View file

@ -10,34 +10,50 @@ import androidx.credentials.exceptions.ClearCredentialUnsupportedException
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialUnsupportedException
import androidx.credentials.provider.AuthenticationAction
import androidx.credentials.provider.BeginCreateCredentialRequest
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.CreateEntry
import androidx.credentials.provider.CredentialEntry
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
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.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
private const val CREATE_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY"
private const val GET_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY"
const val UNLOCK_ACCOUNT_INTENT = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT"
/**
* The default implementation of [Fido2ProviderProcessor]. Its purpose is to handle FIDO2 related
* processing.
*/
@Suppress("LongParameterList")
@RequiresApi(Build.VERSION_CODES.S)
class Fido2ProviderProcessorImpl(
private val context: Context,
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
private val fido2CredentialStore: Fido2CredentialStore,
private val fido2CredentialManager: Fido2CredentialManager,
private val intentManager: IntentManager,
dispatcherManager: DispatcherManager,
) : Fido2ProviderProcessor {
@ -124,10 +140,106 @@ class Fido2ProviderProcessorImpl(
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
) {
// no-op: RFU
callback.onError(GetCredentialUnsupportedException())
// If the user is not logged in, return an error.
val userState = authRepository.userStateFlow.value
if (userState == null) {
callback.onError(GetCredentialUnknownException("Active user is required."))
return
}
// Return an unlock action if the current account is locked.
if (!userState.activeAccount.isVaultUnlocked) {
val authenticationAction = AuthenticationAction(
title = context.getString(R.string.unlock),
pendingIntent = intentManager.createFido2UnlockPendingIntent(
action = UNLOCK_ACCOUNT_INTENT,
requestCode = requestCode.getAndIncrement(),
),
)
callback.onResult(
BeginGetCredentialResponse(
authenticationActions = listOf(authenticationAction),
),
)
return
}
// Otherwise, find all matching credentials from the current vault.
val getCredentialJob = scope.launch {
try {
val credentialEntries = getMatchingFido2CredentialEntries(
userId = userState.activeUserId,
request = request,
)
callback.onResult(
BeginGetCredentialResponse(
credentialEntries = credentialEntries,
),
)
} catch (e: GetCredentialException) {
callback.onError(e)
}
}
cancellationSignal.setOnCancelListener {
callback.onError(GetCredentialCancellationException())
getCredentialJob.cancel()
}
}
@Throws
private suspend fun getMatchingFido2CredentialEntries(
userId: String,
request: BeginGetCredentialRequest,
): List<CredentialEntry> =
request
.beginGetCredentialOptions
.flatMap { option ->
if (option is BeginGetPublicKeyCredentialOption) {
val relyingPartyId = fido2CredentialManager
.getPasskeyAssertionOptionsOrNull(requestJson = option.requestJson)
?.relyingPartyId
?: throw GetCredentialUnknownException("Invalid data.")
vaultRepository
.silentlyDiscoverCredentials(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
)
.fold(
onSuccess = { it.toCredentialEntries(option) },
onFailure = {
throw GetCredentialUnknownException("Error decrypting credentials.")
},
)
} else {
throw GetCredentialUnsupportedException("Unsupported option.")
}
}
private fun List<Fido2CredentialAutofillView>.toCredentialEntries(
option: BeginGetPublicKeyCredentialOption,
): List<CredentialEntry> =
this
.map {
PublicKeyCredentialEntry
.Builder(
context = context,
username = it.userNameForUi ?: context.getString(R.string.no_username),
pendingIntent = intentManager
.createFido2GetCredentialPendingIntent(
action = GET_PASSKEY_INTENT,
credentialId = it.credentialId.toString(),
cipherId = it.cipherId,
requestCode = requestCode.getAndIncrement(),
),
beginGetPublicKeyCredentialOption = option,
)
.build()
}
override fun processClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,

View file

@ -64,6 +64,7 @@ class AutofillParserImpl(
/**
* Parse the [AssistStructure] into an [AutofillRequest].
*/
@Suppress("LongMethod")
private fun parseInternal(
assistStructure: AssistStructure,
autofillAppInfo: AutofillAppInfo,
@ -71,13 +72,24 @@ class AutofillParserImpl(
): AutofillRequest {
// Parse the `assistStructure` into internal models.
val traversalDataList = assistStructure.traverse()
// Flatten the autofill views for processing.
val autofillViews = traversalDataList
.map { it.autofillViews }
// Take only the autofill views from the node that currently has focus.
// Then remove all the fields that cannot be filled with data.
// We fallback to taking all the fillable views if nothing has focus.
val autofillViewsList = traversalDataList.map { it.autofillViews }
val autofillViews = autofillViewsList
.filter { views -> views.any { it.data.isFocused } }
.flatten()
.filter { it !is AutofillView.Unused }
.takeUnless { it.isEmpty() }
?: autofillViewsList
.flatten()
.filter { it !is AutofillView.Unused }
// Find the focused view.
val focusedView = autofillViews.firstOrNull { it.data.isFocused }
// Find the focused view, or fallback to the first fillable item on the screen (so
// we at least have something to hook into)
val focusedView = autofillViews
.firstOrNull { it.data.isFocused }
?: autofillViews.firstOrNull()
val packageName = traversalDataList.buildPackageNameOrNull(
assistStructure = assistStructure,
@ -108,6 +120,7 @@ class AutofillParserImpl(
is AutofillView.Unused -> {
// The view is unfillable since the field is not meant to be used for autofill.
// This will never happen since we filter out all unused views above.
return AutofillRequest.Unfillable
}
}

View file

@ -446,4 +446,15 @@ interface VaultSdkSource {
userId: String,
vararg cipherViews: CipherView,
): Result<List<Fido2CredentialAutofillView>>
/**
* Silently discovers FIDO 2 credentials for a given [userId] and [relyingPartyId].
*
* @return A list of FIDO 2 credentials.
*/
suspend fun silentlyDiscoverCredentials(
userId: String,
fido2CredentialStore: Fido2CredentialStore,
relyingPartyId: String,
): Result<List<Fido2CredentialAutofillView>>
}

View file

@ -32,6 +32,7 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialAuthenticationUserInterfaceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialRegistrationUserInterfaceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialSearchUserInterfaceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
import kotlinx.coroutines.channels.awaitClose
@ -524,6 +525,21 @@ class VaultSdkSourceImpl(
}
}
override suspend fun silentlyDiscoverCredentials(
userId: String,
fido2CredentialStore: Fido2CredentialStore,
relyingPartyId: String,
): Result<List<Fido2CredentialAutofillView>> = runCatching {
getClient(userId)
.platform()
.fido2()
.authenticator(
userInterface = Fido2CredentialSearchUserInterfaceImpl(),
credentialStore = fido2CredentialStore,
)
.silentlyDiscoverCredentials(relyingPartyId)
}
private suspend fun getClient(
userId: String,
): Client = sdkClientManager.getOrCreateClient(userId = userId)

View file

@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.fido.CheckUserOptions
import com.bitwarden.sdk.CheckUserAndPickCredentialForCreationResult
import com.bitwarden.sdk.CheckUserResult
import com.bitwarden.sdk.CipherViewWrapper
import com.bitwarden.sdk.Fido2UserInterface
import com.bitwarden.sdk.UiHint
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.Fido2CredentialNewView
/**
* Implementation of [Fido2UserInterface] for searching for matching FIDO 2 credentials.
*/
class Fido2CredentialSearchUserInterfaceImpl : Fido2UserInterface {
override suspend fun checkUser(
options: CheckUserOptions,
hint: UiHint,
): CheckUserResult =
CheckUserResult(
userPresent = true,
userVerified = true,
)
override suspend fun checkUserAndPickCredentialForCreation(
options: CheckUserOptions,
newCredential: Fido2CredentialNewView,
): CheckUserAndPickCredentialForCreationResult = throw IllegalStateException()
// Always return true for this property because any problems with verification should
// be handled downstream where the app can actually offer verification methods.
override suspend fun isVerificationEnabled(): Boolean = true
override suspend fun pickCredentialForAuthentication(
availableCredentials: List<CipherView>,
): CipherViewWrapper = throw IllegalStateException()
}

View file

@ -4,6 +4,8 @@ import android.net.Uri
import com.bitwarden.core.DateTime
import com.bitwarden.crypto.Kdf
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.vault.CipherView
@ -152,6 +154,15 @@ interface VaultRepository : CipherManager, VaultLockManager {
cipherViewList: List<CipherView>,
): DecryptFido2CredentialAutofillViewResult
/**
* Silently discovers FIDO 2 credentials for a given [userId] and [relyingPartyId].
*/
suspend fun silentlyDiscoverCredentials(
userId: String,
fido2CredentialStore: Fido2CredentialStore,
relyingPartyId: String,
): Result<List<Fido2CredentialAutofillView>>
/**
* Emits the totp code result flow to listeners.
*/

View file

@ -6,6 +6,8 @@ import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.crypto.Kdf
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.send.Send
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
@ -538,6 +540,18 @@ class VaultRepositoryImpl(
)
}
override suspend fun silentlyDiscoverCredentials(
userId: String,
fido2CredentialStore: Fido2CredentialStore,
relyingPartyId: String,
): Result<List<Fido2CredentialAutofillView>> =
vaultSdkSource
.silentlyDiscoverCredentials(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
)
override fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) {
mutableTotpCodeResultFlow.tryEmit(totpCodeResult)
}

View file

@ -96,6 +96,26 @@ interface IntentManager {
*/
fun openEmailApp()
/**
* Creates a pending intent to use when providing
* [androidx.credentials.provider.CredentialEntry] instances for FIDO 2 credential filling.
*/
fun createFido2GetCredentialPendingIntent(
action: String,
credentialId: String,
cipherId: String,
requestCode: Int,
): PendingIntent
/**
* Creates a pending intent to use when providing
* [androidx.credentials.provider.AuthenticationAction] instances for FIDO 2 credential filling.
*/
fun createFido2UnlockPendingIntent(
action: String,
requestCode: Int,
): PendingIntent
/**
* Represents file information.
*/

View file

@ -51,6 +51,20 @@ private const val TEMP_CAMERA_IMAGE_DIR: String = "camera_temp"
*/
const val EXTRA_KEY_USER_ID: String = "user_id"
/**
* Key for the credential id included in FIDO 2 provider "get entries".
*
* @see IntentManager.createFido2GetCredentialPendingIntent
*/
const val EXTRA_KEY_CREDENTIAL_ID: String = "credential_id"
/**
* Key for the cipher id included in FIDO 2 provider "get entries".
*
* @see IntentManager.createFido2GetCredentialPendingIntent
*/
const val EXTRA_KEY_CIPHER_ID: String = "cipher_id"
/**
* The default implementation of the [IntentManager] for simplifying the handling of Android
* Intents within a given context.
@ -217,6 +231,39 @@ class IntentManagerImpl(
)
}
override fun createFido2GetCredentialPendingIntent(
action: String,
credentialId: String,
cipherId: String,
requestCode: Int,
): PendingIntent {
val intent = Intent(action)
.setPackage(context.packageName)
.putExtra(EXTRA_KEY_CREDENTIAL_ID, credentialId)
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
return PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ requestCode,
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),
)
}
override fun createFido2UnlockPendingIntent(
action: String,
requestCode: Int,
): PendingIntent {
val intent = Intent(action).setPackage(context.packageName)
return PendingIntent.getActivity(
/* context = */ context,
/* requestCode = */ requestCode,
/* intent = */ intent,
/* flags = */ PendingIntent.FLAG_UPDATE_CURRENT.toPendingIntentMutabilityFlag(),
)
}
private fun getCameraFileData(): IntentManager.FileData {
val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR)
val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME)

View file

@ -35,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingConten
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenOverwritePasskeyConfirmationDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
@ -176,6 +177,27 @@ fun VaultAddEditScreen(
)
}
},
onSubmitMasterPasswordFido2Verification = remember(viewModel) {
{
viewModel.trySendAction(
action = VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(it),
)
}
},
onDismissFido2PasswordVerification = remember(viewModel) {
{
viewModel.trySendAction(
action = VaultAddEditAction.Common.DismissFido2PasswordVerificationDialogClick,
)
}
},
onRetryFido2PasswordVerification = remember(viewModel) {
{
viewModel.trySendAction(
action = VaultAddEditAction.Common.RetryFido2PasswordVerificationClick,
)
}
},
)
if (pendingDeleteCipher) {
@ -311,6 +333,9 @@ private fun VaultAddEditItemDialogs(
onAutofillDismissRequest: () -> Unit,
onFido2ErrorDismiss: () -> Unit,
onConfirmOverwriteExistingPasskey: () -> Unit,
onSubmitMasterPasswordFido2Verification: (password: String) -> Unit,
onDismissFido2PasswordVerification: () -> Unit,
onRetryFido2PasswordVerification: () -> Unit,
) {
when (dialogState) {
is VaultAddEditState.DialogState.Loading -> {
@ -356,6 +381,23 @@ private fun VaultAddEditItemDialogs(
)
}
is VaultAddEditState.DialogState.Fido2MasterPasswordPrompt -> {
BitwardenMasterPasswordDialog(
onConfirmClick = { onSubmitMasterPasswordFido2Verification(it) },
onDismissRequest = onDismissFido2PasswordVerification,
)
}
is VaultAddEditState.DialogState.Fido2MasterPasswordError -> {
BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = null,
message = R.string.invalid_master_password.asText(),
),
onDismissRequest = onRetryFido2PasswordVerification,
)
}
null -> Unit
}
}

View file

@ -8,6 +8,8 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
@ -216,6 +218,7 @@ class VaultAddEditViewModel @Inject constructor(
//region Common Handlers
@Suppress("LongMethod")
private fun handleCommonActions(action: VaultAddEditAction.Common) {
when (action) {
is VaultAddEditAction.Common.CustomFieldValueChange -> {
@ -284,6 +287,18 @@ class VaultAddEditViewModel @Inject constructor(
VaultAddEditAction.Common.UserVerificationNotSupported -> {
handleUserVerificationNotSupported()
}
is VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit -> {
handleMasterPasswordFido2VerificationSubmit(action)
}
VaultAddEditAction.Common.DismissFido2PasswordVerificationDialogClick -> {
handleDismissFido2PasswordVerificationDialogClick()
}
VaultAddEditAction.Common.RetryFido2PasswordVerificationClick -> {
handleRetryFido2PasswordVerificationClick()
}
}
}
@ -556,6 +571,10 @@ class VaultAddEditViewModel @Inject constructor(
private fun handleUserVerificationSuccess() {
fido2CredentialManager.isUserVerified = true
getRequestAndRegisterCredential()
}
private fun getRequestAndRegisterCredential() =
specialCircumstanceManager
.specialCircumstance
?.toFido2RequestOrNull()
@ -568,7 +587,6 @@ class VaultAddEditViewModel @Inject constructor(
}
}
?: showFido2ErrorDialog()
}
private fun handleUserVerificationFail() {
fido2CredentialManager.isUserVerified = false
@ -597,9 +615,51 @@ class VaultAddEditViewModel @Inject constructor(
private fun handleUserVerificationNotSupported() {
fido2CredentialManager.isUserVerified = false
val activeAccount = authRepository
.userStateFlow
.value
?.activeAccount
?: run {
showFido2ErrorDialog()
return
}
if (activeAccount.vaultUnlockType == VaultUnlockType.PIN) {
// TODO: https://bitwarden.atlassian.net/browse/PM-9682
} else if (activeAccount.hasMasterPassword) {
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.Fido2MasterPasswordPrompt)
}
} else {
// Prompt the user to set up a PIN for their account.
// TODO: https://bitwarden.atlassian.net/browse/PM-9681
}
}
private fun handleMasterPasswordFido2VerificationSubmit(
action: VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit,
) {
viewModelScope.launch {
val result = authRepository.validatePassword(action.password)
sendAction(
VaultAddEditAction.Internal.ValidateFido2PasswordResultReceive(
result = result,
),
)
}
}
private fun handleDismissFido2PasswordVerificationDialogClick() {
showFido2ErrorDialog()
}
private fun handleRetryFido2PasswordVerificationClick() {
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.Fido2MasterPasswordPrompt)
}
}
private fun handleAddNewCustomFieldClick(
action: VaultAddEditAction.Common.AddNewCustomFieldClick,
) {
@ -1263,6 +1323,10 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditAction.Internal.Fido2RegisterCredentialResultReceive -> {
handleFido2RegisterCredentialResultReceive(action)
}
is VaultAddEditAction.Internal.ValidateFido2PasswordResultReceive -> {
handleValidateFido2PasswordResultReceive(action)
}
}
}
@ -1508,6 +1572,39 @@ class VaultAddEditViewModel @Inject constructor(
sendEvent(VaultAddEditEvent.CompleteFido2Registration(action.result))
}
private fun handleValidateFido2PasswordResultReceive(
action: VaultAddEditAction.Internal.ValidateFido2PasswordResultReceive,
) {
mutableStateFlow.update { it.copy(dialog = null) }
when (action.result) {
ValidatePasswordResult.Error -> {
showFido2ErrorDialog()
}
is ValidatePasswordResult.Success -> {
if (!action.result.isValid) {
fido2CredentialManager.authenticationAttempts += 1
if (fido2CredentialManager.hasAuthenticationAttemptsRemaining()) {
mutableStateFlow.update {
it.copy(
dialog = VaultAddEditState.DialogState.Fido2MasterPasswordError,
)
}
} else {
showFido2ErrorDialog()
}
return
}
fido2CredentialManager.isUserVerified = true
fido2CredentialManager.authenticationAttempts = 0
getRequestAndRegisterCredential()
}
}
}
//endregion Internal Type Handlers
//region Utility Functions
@ -2085,6 +2182,20 @@ data class VaultAddEditState(
*/
@Parcelize
data object OverwritePasskeyConfirmationPrompt : DialogState()
/**
* Displays a dialog to prompt the user for their master password as part of the FIDO 2
* user verification flow.
*/
@Parcelize
data object Fido2MasterPasswordPrompt : DialogState()
/**
* Displays a dialog to alert the user that their password for the FIDO 2 user
* verification flow was incorrect and to retry.
*/
@Parcelize
data object Fido2MasterPasswordError : DialogState()
}
}
@ -2351,6 +2462,23 @@ sealed class VaultAddEditAction {
* User verification cannot be performed with device biometrics or credentials.
*/
data object UserVerificationNotSupported : Common()
/**
* The user has clicked to submit the master password for FIDO 2 verification.
*/
data class MasterPasswordFido2VerificationSubmit(
val password: String,
) : Common()
/**
* The user has clicked to dismiss the FIDO 2 password verification dialog.
*/
data object DismissFido2PasswordVerificationDialogClick : Common()
/**
* The user has clicked to retry the FIDO 2 password verification.
*/
data object RetryFido2PasswordVerificationClick : Common()
}
/**
@ -2695,5 +2823,13 @@ sealed class VaultAddEditAction {
data class Fido2RegisterCredentialResultReceive(
val result: Fido2RegisterCredentialResult,
) : Internal()
/**
* Indicates that a result for verifying the user's master password as part of the FIDO 2
* user verification flow has been received.
*/
data class ValidateFido2PasswordResultReceive(
val result: ValidatePasswordResult,
) : Internal()
}
}

View file

@ -972,7 +972,7 @@ class VaultItemListingViewModel @Inject constructor(
is ValidatePasswordResult.Success -> {
if (!action.result.isValid) {
fido2CredentialManager.authenticationAttempts += 1
if (fido2CredentialManager.authenticationAttempts < 5) {
if (fido2CredentialManager.hasAuthenticationAttemptsRemaining()) {
mutableStateFlow.update {
it.copy(
dialogState = VaultItemListingState

View file

@ -148,6 +148,7 @@
<string name="no_favorites">There are no favorites in your vault.</string>
<string name="no_items">There are no items in your vault.</string>
<string name="no_items_tap">There are no items in your vault for this website/app. Tap to add one.</string>
<string name="no_username">No Username</string>
<string name="no_username_password_configured">This login does not have a username or password configured.</string>
<string name="ok_got_it">Ok, got it!</string>
<string name="option_defaults">Option defaults are set from the main Bitwarden app\'s password generator tool.</string>

View file

@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialRequest
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.util.asFailure
@ -21,6 +22,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockPublicKeyAttestationResponse
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
@ -34,6 +36,7 @@ import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
@ -57,6 +60,9 @@ class Fido2CredentialManagerTest {
every {
decodeFromString<PasskeyAttestationOptions>(any())
} returns createMockPasskeyAttestationOptions(number = 1)
every {
decodeFromString<PasskeyAssertionOptions>(any())
} returns createMockPasskeyAssertionOptions(number = 1)
}
private val mockPrivilegedCallingAppInfo = mockk<CallingAppInfo> {
every { packageName } returns "com.x8bit.bitwarden"
@ -277,7 +283,7 @@ class Fido2CredentialManagerTest {
}
@Test
fun `getPasskeyCreateOptionsOrNull should return null when deserialization fails`() =
fun `getPasskeyAttestationOptionsOrNull should return null when deserialization fails`() =
runTest {
every {
json.decodeFromString<PasskeyAttestationOptions>(any())
@ -291,7 +297,7 @@ class Fido2CredentialManagerTest {
@Suppress("MaxLineLength")
@Test
fun `getPasskeyCreateOptionsOrNull should return null when IllegalArgumentException is thrown`() {
fun `getPasskeyAttestationOptionsOrNull should return null when IllegalArgumentException is thrown`() {
every {
json.decodeFromString<PasskeyAttestationOptions>(any())
} throws IllegalArgumentException()
@ -299,6 +305,39 @@ class Fido2CredentialManagerTest {
assertNull(fido2CredentialManager.getPasskeyAttestationOptionsOrNull(requestJson = ""))
}
@Test
fun `getPasskeyAssertionOptionsOrNull should return options when deserialized`() = runTest {
assertEquals(
createMockPasskeyAssertionOptions(number = 1),
fido2CredentialManager.getPasskeyAssertionOptionsOrNull(
requestJson = "",
),
)
}
@Test
fun `getPasskeyAssertionOptionsOrNull should return null when deserialization fails`() =
runTest {
every {
json.decodeFromString<PasskeyAssertionOptions>(any())
} throws SerializationException()
assertNull(
fido2CredentialManager.getPasskeyAssertionOptionsOrNull(
requestJson = "",
),
)
}
@Suppress("MaxLineLength")
@Test
fun `getPasskeyAssertionOptionsOrNull should return null when IllegalArgumentException is thrown`() {
every {
json.decodeFromString<PasskeyAssertionOptions>(any())
} throws IllegalArgumentException()
assertNull(fido2CredentialManager.getPasskeyAssertionOptionsOrNull(requestJson = ""))
}
@Suppress("MaxLineLength")
@Test
fun `registerFido2Credential should construct ClientData DefaultWithCustomHash when callingAppInfo origin is populated`() =
@ -523,6 +562,19 @@ class Fido2CredentialManagerTest {
result is Fido2RegisterCredentialResult.Error,
)
}
@Suppress("MaxLineLength")
@Test
fun `hasAuthenticationAttemptsRemaining returns true when authenticationAttempts is less than 5`() {
assertTrue(fido2CredentialManager.hasAuthenticationAttemptsRemaining())
}
@Suppress("MaxLineLength")
@Test
fun `hasAuthenticationAttemptsRemaining returns false when authenticationAttempts is greater than 5`() {
fido2CredentialManager.authenticationAttempts = 6
assertFalse(fido2CredentialManager.hasAuthenticationAttemptsRemaining())
}
}
private const val DEFAULT_APP_SIGNATURE = "0987654321ABCDEF"

View file

@ -7,24 +7,49 @@ import android.os.CancellationSignal
import android.os.OutcomeReceiver
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialUnsupportedException
import androidx.credentials.provider.AuthenticationAction
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginCreatePasswordCredentialRequest
import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.BeginGetPasswordOption
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFido2CredentialAutofillView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAssertionOptions
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkConstructor
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.encodeToString
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -35,19 +60,35 @@ class Fido2ProviderProcessorTest {
private val context: Context = mockk()
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val mutableCiphersStateFlow = MutableStateFlow<DataState<List<CipherView>>>(
DataState.Loaded(emptyList()),
)
private val authRepository: AuthRepository = mockk {
every { activeUserId } returns "mockActiveUserId"
every { userStateFlow } returns mutableUserStateFlow
}
private val vaultRepository: VaultRepository = mockk {
every { ciphersStateFlow } returns mutableCiphersStateFlow
}
private val passkeyAssertionOptions = createMockPasskeyAssertionOptions(number = 1)
private val fido2CredentialManager: Fido2CredentialManager = mockk {
every { getPasskeyAssertionOptionsOrNull(any()) } returns passkeyAssertionOptions
}
private val fido2CredentialStore: Fido2CredentialStore = mockk()
private val intentManager: IntentManager = mockk()
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
private val cancellationSignal: CancellationSignal = mockk()
private val json = PlatformNetworkModule.providesJson()
@BeforeEach
fun setUp() {
fido2Processor = Fido2ProviderProcessorImpl(
context,
authRepository,
vaultRepository,
fido2CredentialStore,
fido2CredentialManager,
intentManager,
dispatcherManager,
)
@ -207,6 +248,238 @@ class Fido2ProviderProcessorTest {
assertEquals(mockAccount.email, captureSlot.captured.createEntries[index].accountName)
}
}
@Test
fun `processGetCredentialRequest should invoke callback with error when user state is null`() {
val request: BeginGetCredentialRequest = mockk()
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
val captureSlot = slot<GetCredentialException>()
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { callback.onError(capture(captureSlot)) } just runs
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 1) { callback.onError(any()) }
verify(exactly = 0) { callback.onResult(any()) }
assert(captureSlot.captured is GetCredentialUnknownException)
assertEquals("Active user is required.", captureSlot.captured.errorMessage)
}
@Suppress("MaxLineLength")
@Test
fun `processGetCredentialRequest should invoke callback with authentication action when vault is locked`() {
val request: BeginGetCredentialRequest = mockk()
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
val captureSlot = slot<BeginGetCredentialResponse>()
val mockIntent: PendingIntent = mockk()
mutableUserStateFlow.value = DEFAULT_USER_STATE
.copy(
accounts = listOf(
DEFAULT_USER_STATE
.accounts
.first { it.userId == "mockUserId-1" }
.copy(isVaultUnlocked = false),
),
)
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { callback.onResult(capture(captureSlot)) } just runs
every { context.getString(any()) } returns "mockTitle"
every {
intentManager.createFido2UnlockPendingIntent(
action = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT",
requestCode = any(),
)
} returns mockIntent
val expected = AuthenticationAction(
title = "mockTitle",
pendingIntent = mockIntent,
)
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 0) { callback.onError(any()) }
verify(exactly = 1) {
callback.onResult(any())
intentManager.createFido2UnlockPendingIntent(
action = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT",
requestCode = any(),
)
}
assertEquals(
expected.title,
captureSlot.captured.authenticationActions.first().title,
)
assertEquals(
expected.pendingIntent,
captureSlot.captured.authenticationActions.first().pendingIntent,
)
}
@Suppress("MaxLineLength")
@Test
fun `processGetCredentialRequest should invoke callback with error when option is not BeginGetPublicKeyCredentialOption`() {
val request: BeginGetCredentialRequest = mockk {
every { beginGetCredentialOptions } returns listOf(mockk<BeginGetPasswordOption>())
}
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
val captureSlot = slot<GetCredentialException>()
mutableUserStateFlow.value = DEFAULT_USER_STATE
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { callback.onError(capture(captureSlot)) } just runs
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 1) { callback.onError(any()) }
verify(exactly = 0) { callback.onResult(any()) }
assert(captureSlot.captured is GetCredentialUnsupportedException)
assertEquals("Unsupported option.", captureSlot.captured.errorMessage)
}
@Suppress("MaxLineLength")
@Test
fun `processGetCredentialRequest should invoke callback with error when option does not contain valid request json`() {
val mockOption = BeginGetPublicKeyCredentialOption(
candidateQueryData = Bundle(),
id = "",
requestJson = json.encodeToString(passkeyAssertionOptions),
)
val request: BeginGetCredentialRequest = mockk {
every { beginGetCredentialOptions } returns listOf(mockOption)
}
every {
fido2CredentialManager.getPasskeyAssertionOptionsOrNull(any())
} returns null
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
val captureSlot = slot<GetCredentialException>()
mutableUserStateFlow.value = DEFAULT_USER_STATE
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { callback.onError(capture(captureSlot)) } just runs
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 1) { callback.onError(any()) }
verify(exactly = 0) { callback.onResult(any()) }
assert(captureSlot.captured is GetCredentialUnknownException)
assertEquals("Invalid data.", captureSlot.captured.errorMessage)
}
@Suppress("MaxLineLength")
@Test
fun `processGetCredentialRequest should invoke callback with error when discovering passkey fails`() {
val mockOption = BeginGetPublicKeyCredentialOption(
candidateQueryData = Bundle(),
id = "",
requestJson = json.encodeToString(passkeyAssertionOptions),
)
val request: BeginGetCredentialRequest = mockk {
every { beginGetCredentialOptions } returns listOf(mockOption)
}
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
val captureSlot = slot<GetCredentialException>()
val mockCipherViews = listOf(createMockCipherView(number = 1))
mutableUserStateFlow.value = DEFAULT_USER_STATE
mutableCiphersStateFlow.value = DataState.Loaded(mockCipherViews)
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { callback.onError(capture(captureSlot)) } just runs
coEvery {
vaultRepository.silentlyDiscoverCredentials(
userId = DEFAULT_USER_STATE.activeUserId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = "mockRelyingPartyId-1",
)
} returns Throwable().asFailure()
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 1) { callback.onError(any()) }
verify(exactly = 0) { callback.onResult(any()) }
coVerify(exactly = 1) {
vaultRepository.silentlyDiscoverCredentials(
userId = DEFAULT_USER_STATE.activeUserId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = "mockRelyingPartyId-1",
)
}
assert(captureSlot.captured is GetCredentialUnknownException)
assertEquals("Error decrypting credentials.", captureSlot.captured.errorMessage)
}
@Suppress("MaxLineLength")
@Test
fun `processGetCredentialRequest should invoke callback with filtered and discovered passkeys`() {
val mockOption = BeginGetPublicKeyCredentialOption(
candidateQueryData = Bundle(),
id = "",
requestJson = json.encodeToString(passkeyAssertionOptions),
)
val request: BeginGetCredentialRequest = mockk {
every { beginGetCredentialOptions } returns listOf(mockOption)
}
val callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException> = mockk()
val captureSlot = slot<BeginGetCredentialResponse>()
val mockCipherViews = listOf(createMockCipherView(number = 1))
val mockFido2CredentialAutofillViews = listOf(
createMockFido2CredentialAutofillView(number = 1),
)
val mockIntent: PendingIntent = mockk()
val mockPublicKeyCredentialEntry: PublicKeyCredentialEntry = mockk()
mutableUserStateFlow.value = DEFAULT_USER_STATE
mutableCiphersStateFlow.value = DataState.Loaded(mockCipherViews)
every { cancellationSignal.setOnCancelListener(any()) } just runs
every { callback.onResult(capture(captureSlot)) } just runs
coEvery {
vaultRepository.silentlyDiscoverCredentials(
userId = DEFAULT_USER_STATE.activeUserId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = "mockRelyingPartyId-1",
)
} returns mockFido2CredentialAutofillViews.asSuccess()
every {
intentManager.createFido2GetCredentialPendingIntent(
action = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY",
credentialId = mockFido2CredentialAutofillViews.first().credentialId.toString(),
cipherId = mockFido2CredentialAutofillViews.first().cipherId,
requestCode = any(),
)
} returns mockIntent
mockkConstructor(PublicKeyCredentialEntry.Builder::class)
every {
anyConstructed<PublicKeyCredentialEntry.Builder>().build()
} returns mockPublicKeyCredentialEntry
fido2Processor.processGetCredentialRequest(request, cancellationSignal, callback)
verify(exactly = 0) { callback.onError(any()) }
verify(exactly = 1) {
callback.onResult(any())
intentManager.createFido2GetCredentialPendingIntent(
action = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY",
credentialId = mockFido2CredentialAutofillViews.first().credentialId.toString(),
cipherId = mockFido2CredentialAutofillViews.first().cipherId,
requestCode = any(),
)
}
coVerify(exactly = 1) {
vaultRepository.silentlyDiscoverCredentials(
userId = DEFAULT_USER_STATE.activeUserId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = "mockRelyingPartyId-1",
)
}
assertEquals(1, captureSlot.captured.credentialEntries.size)
assertEquals(mockPublicKeyCredentialEntry, captureSlot.captured.credentialEntries.first())
unmockkConstructor(PublicKeyCredentialEntry.Builder::class)
}
}
private val DEFAULT_USER_STATE = UserState(

View file

@ -536,6 +536,66 @@ class AutofillParserTests {
}
}
@Suppress("MaxLineLength")
@Test
fun `parse should choose first fillable AutofillView for partition when there is no focused view`() {
// Setup
setupAssistStructureWithAllAutofillViewTypes()
val cardAutofillView: AutofillView.Card = AutofillView.Card.ExpirationMonth(
data = AutofillView.Data(
autofillId = cardAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = false,
textValue = null,
),
monthValue = null,
)
val loginAutofillView: AutofillView.Login = AutofillView.Login.Username(
data = AutofillView.Data(
autofillId = loginAutofillId,
autofillOptions = emptyList(),
autofillType = AUTOFILL_TYPE,
isFocused = false,
textValue = null,
),
)
val autofillPartition = AutofillPartition.Card(
views = listOf(cardAutofillView),
)
val expected = AutofillRequest.Fillable(
ignoreAutofillIds = emptyList(),
inlinePresentationSpecs = inlinePresentationSpecs,
maxInlineSuggestionsCount = MAX_INLINE_SUGGESTION_COUNT,
packageName = PACKAGE_NAME,
partition = autofillPartition,
uri = URI,
)
every { cardViewNode.toAutofillView() } returns cardAutofillView
every { loginViewNode.toAutofillView() } returns loginAutofillView
// Test
val actual = parser.parse(
autofillAppInfo = autofillAppInfo,
fillRequest = fillRequest,
)
// Verify
assertEquals(expected, actual)
verify(exactly = 1) {
fillRequest.getInlinePresentationSpecs(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = true,
)
fillRequest.getMaxInlineSuggestionsCount(
autofillAppInfo = autofillAppInfo,
isInlineAutofillEnabled = true,
)
any<List<ViewNodeTraversalData>>().buildPackageNameOrNull(assistStructure)
any<List<ViewNodeTraversalData>>().buildUriOrNull(PACKAGE_NAME)
}
}
@Test
fun `parse should return empty inline suggestions when inline autofill is disabled`() {
// Setup

View file

@ -18,6 +18,7 @@ import com.bitwarden.sdk.ClientCiphers
import com.bitwarden.sdk.ClientCrypto
import com.bitwarden.sdk.ClientExporters
import com.bitwarden.sdk.ClientFido2
import com.bitwarden.sdk.ClientFido2Authenticator
import com.bitwarden.sdk.ClientFido2Client
import com.bitwarden.sdk.ClientPasswordHistory
import com.bitwarden.sdk.ClientPlatform
@ -42,6 +43,7 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.Fido2CredentialSearchUserInterfaceImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
@ -1134,6 +1136,67 @@ class VaultSdkSourceTest {
cipherViews = arrayOf(mockCipherView),
)
assertTrue(result.isFailure)
}
@Test
fun `silentlyDiscoverCredentials should return results when successful`() = runTest {
val userId = "userId"
val fido2CredentialStore: Fido2CredentialStore = mockk()
val relyingPartyId = "relyingPartyId"
val mockAutofillView = Fido2CredentialAutofillView(
credentialId = byteArrayOf(0),
cipherId = "mockCipherId",
rpId = "mockRpId",
userNameForUi = "mockUserNameForUi",
userHandle = "mockUserHandle".toByteArray(),
)
val autofillViews = listOf(mockAutofillView)
val authenticator: ClientFido2Authenticator = mockk {
coEvery { silentlyDiscoverCredentials(relyingPartyId) } returns autofillViews
}
every {
clientFido2.authenticator(
userInterface = any(),
credentialStore = fido2CredentialStore,
)
} returns authenticator
val result = vaultSdkSource.silentlyDiscoverCredentials(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
)
assertEquals(
autofillViews.asSuccess(),
result,
)
}
@Test
fun `silentlyDiscoverCredentials should return Failure when Bitwarden exception is thrown`() =
runTest {
val userId = "userId"
val fido2CredentialStore: Fido2CredentialStore = mockk()
val relyingPartyId = "relyingPartyId"
coEvery {
clientFido2
.authenticator(
userInterface = Fido2CredentialSearchUserInterfaceImpl(),
credentialStore = fido2CredentialStore,
)
.silentlyDiscoverCredentials(relyingPartyId)
} throws BitwardenException.E("mockException")
val result = vaultSdkSource.silentlyDiscoverCredentials(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
)
assertTrue(result.isFailure)
}
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.vault.Fido2CredentialView
import java.time.Instant
@ -21,3 +22,16 @@ fun createMockFido2CredentialView(number: Int): Fido2CredentialView = Fido2Crede
discoverable = "mockDiscoverable-$number",
creationDate = Instant.now(),
)
/**
* Creates a [createMockFido2CredentialAutofillView] instance for testing.
*/
fun createMockFido2CredentialAutofillView(
number: Int,
): Fido2CredentialAutofillView = Fido2CredentialAutofillView(
credentialId = "mockCredentialId-$number".toByteArray(),
cipherId = "mockCipherId-$number",
rpId = "mockRpId-$number",
userNameForUi = "mockUserNameForUi-$number",
userHandle = "mockUserHandle-$number".toByteArray(),
)

View file

@ -9,6 +9,7 @@ import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.vault.CipherView
@ -4256,6 +4257,42 @@ class VaultRepositoryTest {
}
}
@Test
fun `silentlyDiscoverCredentials should return result`() = runTest {
val userId = "userId"
val fido2CredentialStore: Fido2CredentialStore = mockk()
val relyingPartyId = "relyingPartyId"
val expected: Result<List<Fido2CredentialAutofillView>> = mockk()
coEvery {
vaultSdkSource.silentlyDiscoverCredentials(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
)
} returns expected
turbineScope {
val result = vaultRepository.silentlyDiscoverCredentials(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
)
assertEquals(
expected,
result,
)
}
coVerify {
vaultSdkSource.silentlyDiscoverCredentials(
userId = userId,
fido2CredentialStore = fido2CredentialStore,
relyingPartyId = relyingPartyId,
)
}
}
//region Helper functions
/**

View file

@ -219,6 +219,85 @@ class VaultAddEditScreenTest : BaseComposeTest() {
.assertIsDisplayed()
}
@Test
fun `fido2 master password prompt dialog should display based on state`() {
val dialogTitle = "Master password confirmation"
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(dialogTitle).assertDoesNotExist()
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.Fido2MasterPasswordPrompt)
}
composeTestRule
.onNodeWithText(dialogTitle)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultAddEditAction.Common.DismissFido2PasswordVerificationDialogClick,
)
}
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultAddEditAction.Common.DismissFido2PasswordVerificationDialogClick,
)
}
composeTestRule
.onAllNodesWithText(text = "Master password")
.filterToOne(hasAnyAncestor(isDialog()))
.performTextInput("password")
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(
password = "password",
),
)
}
}
@Test
fun `fido2 master password error dialog should display based on state`() {
val dialogMessage = "Invalid master password. Try again."
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(dialogMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.Fido2MasterPasswordError)
}
composeTestRule
.onNodeWithText(dialogMessage)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
composeTestRule
.onAllNodesWithText(text = "Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
VaultAddEditAction.Common.RetryFido2PasswordVerificationClick,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `clicking dismiss dialog on Fido2Error dialog should send Fido2ErrorDialogDismissed action`() {

View file

@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
@ -126,6 +127,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
private val fido2CredentialManager = mockk<Fido2CredentialManager> {
every { isUserVerified } returns false
every { isUserVerified = any() } just runs
every { authenticationAttempts } returns 0
every { authenticationAttempts = any() } just runs
every { hasAuthenticationAttemptsRemaining() } returns true
}
private val vaultRepository: VaultRepository = mockk {
every { vaultDataStateFlow } returns mutableVaultDataFlow
@ -3168,11 +3172,145 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `UserVerificationNotSupported should set isUserVerified to false and show Fido2ErrorDialog`() {
fun `UserVerificationNotSupported should display Fido2ErrorDialog when active account not found`() {
mutableUserStateFlow.value = null
viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationNotSupported)
verify { fido2CredentialManager.isUserVerified = false }
assertEquals(
VaultAddEditState.DialogState.Fido2Error(R.string.passkey_operation_failed_because_user_could_not_be_verified.asText()),
VaultAddEditState.DialogState.Fido2Error(
message = R.string.passkey_operation_failed_because_user_could_not_be_verified.asText(),
),
viewModel.stateFlow.value.dialog,
)
}
@Suppress("MaxLineLength")
@Test
fun `UserVerificationNotSupported should display Fido2MasterPasswordPrompt when user has password but no pin`() {
viewModel.trySendAction(VaultAddEditAction.Common.UserVerificationNotSupported)
verify { fido2CredentialManager.isUserVerified = false }
assertEquals(
VaultAddEditState.DialogState.Fido2MasterPasswordPrompt,
viewModel.stateFlow.value.dialog,
)
}
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordFido2VerificationSubmit should display Fido2Error when password verification fails`() {
val password = "password"
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Error
viewModel.trySendAction(
VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(
password = password,
),
)
assertEquals(
VaultAddEditState.DialogState.Fido2Error(
message = R.string.passkey_operation_failed_because_user_could_not_be_verified
.asText(),
),
viewModel.stateFlow.value.dialog,
)
coVerify {
authRepository.validatePassword(password = password)
}
}
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordFido2VerificationSubmit should display Fido2MasterPasswordError when user has retries remaining`() {
val password = "password"
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = false)
viewModel.trySendAction(
VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(
password = password,
),
)
assertEquals(
VaultAddEditState.DialogState.Fido2MasterPasswordError,
viewModel.stateFlow.value.dialog,
)
coVerify {
authRepository.validatePassword(password = password)
}
}
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordFido2VerificationSubmit should display Fido2Error when user has no retries remaining`() {
val password = "password"
every { fido2CredentialManager.hasAuthenticationAttemptsRemaining() } returns false
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = false)
viewModel.trySendAction(
VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(
password = password,
),
)
assertEquals(
VaultAddEditState.DialogState.Fido2Error(
message = R.string.passkey_operation_failed_because_user_could_not_be_verified
.asText(),
),
viewModel.stateFlow.value.dialog,
)
coVerify {
authRepository.validatePassword(password = password)
}
}
@Suppress("MaxLineLength")
@Test
fun `MasterPasswordFido2VerificationSubmit should register credential when password authenticated successfully`() =
runTest {
val password = "password"
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = true)
viewModel.trySendAction(
VaultAddEditAction.Common.MasterPasswordFido2VerificationSubmit(
password = password,
),
)
coVerify {
authRepository.validatePassword(password = password)
}
}
@Test
fun `DismissFido2PasswordVerificationDialogClick should display Fido2ErrorDialog`() {
viewModel.trySendAction(
VaultAddEditAction.Common.DismissFido2PasswordVerificationDialogClick,
)
assertEquals(
VaultAddEditState.DialogState.Fido2Error(
message = R.string.passkey_operation_failed_because_user_could_not_be_verified
.asText(),
),
viewModel.stateFlow.value.dialog,
)
}
@Test
fun `RetryFido2PasswordVerificationClick should display Fido2MasterPasswordPrompt`() {
viewModel.trySendAction(VaultAddEditAction.Common.RetryFido2PasswordVerificationClick)
assertEquals(
VaultAddEditState.DialogState.Fido2MasterPasswordPrompt,
viewModel.stateFlow.value.dialog,
)
}
@ -3404,6 +3542,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
}
}
}
//region Helper functions
@Suppress("MaxLineLength")

View file

@ -0,0 +1,23 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit.util
import com.x8bit.bitwarden.data.autofill.fido2.model.PublicKeyCredentialDescriptor
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
/**
* Returns a mock FIDO 2 [PasskeyAssertionOptions] object to simulate a credential
* creation request.
*/
fun createMockPasskeyAssertionOptions(
number: Int,
) = PasskeyAssertionOptions(
challenge = "mockChallenge-$number",
allowCredentials = listOf(
PublicKeyCredentialDescriptor(
type = "mockPublicKeyCredentialDescriptorType-$number",
id = "mockPublicKeyCredentialDescriptorId-$number",
transports = listOf("mockPublicKeyCredentialDescriptorTransports-$number"),
),
),
relyingPartyId = "mockRelyingPartyId-$number",
userVerification = "mockUserVerification-$number",
)

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit.util
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PublicKeyCredentialDescriptor
/**
* Returns a mock FIDO 2 [PasskeyAttestationOptions] object to simulate a credential
@ -15,7 +16,7 @@ fun createMockPasskeyAttestationOptions(
.AuthenticatorSelectionCriteria(userVerification = userVerificationRequirement),
challenge = "mockPublicKeyCredentialCreationOptionsChallenge-$number",
excludeCredentials = listOf(
PasskeyAttestationOptions.PublicKeyCredentialDescriptor(
PublicKeyCredentialDescriptor(
type = "mockPublicKeyCredentialDescriptorType-$number",
id = "mockPublicKeyCredentialDescriptorId-$number",
transports = listOf("mockPublicKeyCredentialDescriptorTransports-$number"),

View file

@ -146,6 +146,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
every { isUserVerified = any() } just runs
every { authenticationAttempts } returns 0
every { authenticationAttempts = any() } just runs
every { hasAuthenticationAttemptsRemaining() } returns true
}
private val organizationEventManager = mockk<OrganizationEventManager> {
@ -2471,7 +2472,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
val viewModel = createVaultItemListingViewModel()
val selectedCipherId = "selectedCipherId"
val password = "password"
every { fido2CredentialManager.authenticationAttempts } returns 5
every { fido2CredentialManager.hasAuthenticationAttemptsRemaining() } returns false
coEvery {
authRepository.validatePassword(password = password)
} returns ValidatePasswordResult.Success(isValid = false)

View file

@ -38,8 +38,8 @@ kotlin = "2.0.0"
kotlinxCollectionsImmutable = "0.3.7"
kotlinxCoroutines = "1.8.1"
kotlinxSerialization = "1.7.1"
kotlinxKover = "0.8.2"
ksp = "2.0.0-1.0.22"
kotlinxKover = "0.8.3"
ksp = "2.0.0-1.0.23"
mockk = "1.13.12"
okhttp = "4.12.0"
retrofitBom = "2.11.0"