From 7cf753685788e5e6a7192071d663abbf1589b4b0 Mon Sep 17 00:00:00 2001 From: Shannon Draeker <125921730+shannon-livefront@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:53:44 -0600 Subject: [PATCH] PM-9682: Verify with PIN on item listing (#3600) --- .../data/auth/repository/AuthRepository.kt | 6 + .../auth/repository/AuthRepositoryImpl.kt | 48 ++++ .../repository/model/ValidatePinResult.kt | 18 ++ .../components/dialog/BitwardenPinDialog.kt | 78 ++++++ .../itemlisting/VaultItemListingScreen.kt | 63 ++++- .../itemlisting/VaultItemListingViewModel.kt | 219 ++++++++++++---- .../auth/repository/AuthRepositoryTest.kt | 164 ++++++++++++ .../itemlisting/VaultItemListingScreenTest.kt | 98 +++++++- .../VaultItemListingViewModelTest.kt | 233 ++++++++++++++++-- 9 files changed, 853 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/ValidatePinResult.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenPinDialog.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 82ba6f7fa..5773a6273 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult @@ -342,6 +343,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { */ suspend fun validatePassword(password: String): ValidatePasswordResult + /** + * Validates the PIN for the current logged in user. + */ + suspend fun validatePin(pin: String): ValidatePinResult + /** * Validates the given [password] against the master password * policies for the current user. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index f00198402..5e5b01e9f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.repository import android.os.SystemClock import com.bitwarden.core.AuthRequestMethod import com.bitwarden.core.InitUserCryptoMethod +import com.bitwarden.core.InitUserCryptoRequest import com.bitwarden.crypto.HashPurpose import com.bitwarden.crypto.Kdf import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource @@ -54,6 +55,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult @@ -87,6 +89,7 @@ import com.x8bit.bitwarden.data.platform.util.flatMap import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult @@ -1103,6 +1106,51 @@ class AuthRepositoryImpl( } } + override suspend fun validatePin(pin: String): ValidatePinResult { + val activeAccount = authDiskSource + .userState + ?.activeAccount + ?.profile + ?: return ValidatePinResult.Error + val privateKey = authDiskSource + .getPrivateKey(userId = activeAccount.userId) + ?: return ValidatePinResult.Error + val pinProtectedUserKey = authDiskSource + .getPinProtectedUserKey(userId = activeAccount.userId) + ?: return ValidatePinResult.Error + + // HACK: As the SDK doesn't provide a way to directly validate the pin yet, we instead + // try to initialize the user crypto, and if it succeeds then the PIN is correct, otherwise + // the PIN is incorrect. + return vaultSdkSource + .initializeCrypto( + userId = activeAccount.userId, + request = InitUserCryptoRequest( + kdfParams = activeAccount.toSdkParams(), + email = activeAccount.email, + privateKey = privateKey, + method = InitUserCryptoMethod.Pin( + pin = pin, + pinProtectedUserKey = pinProtectedUserKey, + ), + ), + ) + .fold( + onSuccess = { + when (it) { + InitializeCryptoResult.Success -> { + ValidatePinResult.Success(isValid = true) + } + + InitializeCryptoResult.AuthenticationError -> { + ValidatePinResult.Success(isValid = false) + } + } + }, + onFailure = { ValidatePinResult.Error }, + ) + } + override suspend fun validatePasswordAgainstPolicies( password: String, ): Boolean = passwordPolicies diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/ValidatePinResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/ValidatePinResult.kt new file mode 100644 index 000000000..1ecef7674 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/ValidatePinResult.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +/** + * Models result of determining if a PIN is valid. + */ +sealed class ValidatePinResult { + /** + * The validity of the PIN was checked successfully and [isValid]. + */ + data class Success( + val isValid: Boolean, + ) : ValidatePinResult() + + /** + * There was an error determining if the validity of the PIN. + */ + data object Error : ValidatePinResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenPinDialog.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenPinDialog.kt new file mode 100644 index 000000000..b8957f02e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenPinDialog.kt @@ -0,0 +1,78 @@ +package com.x8bit.bitwarden.ui.platform.components.dialog + +import androidx.compose.foundation.layout.imePadding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.testTagsAsResourceId +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton +import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField + +/** + * Represents a Bitwarden-styled dialog for the user to enter their PIN. + * + * @param onConfirmClick called when the confirm button is clicked and emits the entered PIN. + * @param onDismissRequest called when the user attempts to dismiss the dialog (for example by + * tapping outside of it). + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun BitwardenPinDialog( + onConfirmClick: (masterPassword: String) -> Unit, + onDismissRequest: () -> Unit, +) { + var pin by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismissRequest, + dismissButton = { + BitwardenTextButton( + label = stringResource(id = R.string.cancel), + onClick = onDismissRequest, + modifier = Modifier.testTag("DismissAlertButton"), + ) + }, + confirmButton = { + BitwardenTextButton( + label = stringResource(id = R.string.submit), + isEnabled = pin.isNotEmpty(), + onClick = { onConfirmClick(pin) }, + modifier = Modifier.testTag("AcceptAlertButton"), + ) + }, + title = { + Text( + text = stringResource(id = R.string.verify_pin), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.testTag("AlertTitleText"), + ) + }, + text = { + BitwardenPasswordField( + label = stringResource(id = R.string.pin), + value = pin, + onValueChange = { pin = it }, + modifier = Modifier + .testTag("AlertInputField") + .imePadding(), + autoFocus = true, + ) + }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.semantics { + testTagsAsResourceId = true + testTag = "AlertPopup" + }, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index d98586cb2..8959f2250 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -43,6 +43,7 @@ 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.BitwardenPinDialog import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter @@ -197,13 +198,6 @@ fun VaultItemListingScreen( ) } }, - onDismissFido2PasswordVerification = remember(viewModel) { - { - viewModel.trySendAction( - VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick, - ) - } - }, onRetryFido2PasswordVerification = remember(viewModel) { { viewModel.trySendAction( @@ -211,6 +205,30 @@ fun VaultItemListingScreen( ) } }, + onSubmitPinFido2Verification = remember(viewModel) { + { pin, cipherId -> + viewModel.trySendAction( + VaultItemListingsAction.PinFido2VerificationSubmit( + pin = pin, + selectedCipherId = cipherId, + ), + ) + } + }, + onRetryFido2PinVerification = remember(viewModel) { + { + viewModel.trySendAction( + VaultItemListingsAction.RetryFido2PinVerificationClick(it), + ) + } + }, + onDismissFido2Verification = remember(viewModel) { + { + viewModel.trySendAction( + VaultItemListingsAction.DismissFido2VerificationDialogClick, + ) + } + }, ) VaultItemListingScaffold( @@ -222,6 +240,7 @@ fun VaultItemListingScreen( ) } +@Suppress("LongMethod") @Composable private fun VaultItemListingDialogs( dialogState: VaultItemListingState.DialogState?, @@ -229,8 +248,10 @@ private fun VaultItemListingDialogs( onDismissFido2ErrorDialog: () -> Unit, onConfirmOverwriteExistingPasskey: (cipherViewId: String) -> Unit, onSubmitMasterPasswordFido2Verification: (password: String, cipherId: String) -> Unit, - onDismissFido2PasswordVerification: () -> Unit, onRetryFido2PasswordVerification: (cipherViewId: String) -> Unit, + onSubmitPinFido2Verification: (pin: String, cipherId: String) -> Unit, + onRetryFido2PinVerification: (cipherViewId: String) -> Unit, + onDismissFido2Verification: () -> Unit, ) { when (dialogState) { is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog( @@ -268,7 +289,7 @@ private fun VaultItemListingDialogs( dialogState.selectedCipherId, ) }, - onDismissRequest = onDismissFido2PasswordVerification, + onDismissRequest = onDismissFido2Verification, ) } @@ -284,6 +305,30 @@ private fun VaultItemListingDialogs( ) } + is VaultItemListingState.DialogState.Fido2PinPrompt -> { + BitwardenPinDialog( + onConfirmClick = { pin -> + onSubmitPinFido2Verification( + pin, + dialogState.selectedCipherId, + ) + }, + onDismissRequest = onDismissFido2Verification, + ) + } + + is VaultItemListingState.DialogState.Fido2PinError -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialogState.title, + message = dialogState.message, + ), + onDismissRequest = { + onRetryFido2PinVerification(dialogState.selectedCipherId) + }, + ) + } + null -> Unit } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 8bd1d5935..560c03227 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -8,6 +8,7 @@ import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult 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 @@ -189,14 +190,22 @@ class VaultItemListingViewModel @Inject constructor( handleMasterPasswordFido2VerificationSubmit(action) } - VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick -> { - handleDismissFido2PasswordVerificationDialogClick() - } - is VaultItemListingsAction.RetryFido2PasswordVerificationClick -> { handleRetryFido2PasswordVerificationClick(action) } + is VaultItemListingsAction.PinFido2VerificationSubmit -> { + handlePinFido2VerificationSubmit(action) + } + + is VaultItemListingsAction.RetryFido2PinVerificationClick -> { + handleRetryFido2PinVerificationClick(action) + } + + VaultItemListingsAction.DismissFido2VerificationDialogClick -> { + handleDismissFido2VerificationDialogClick() + } + is VaultItemListingsAction.BackClick -> handleBackClick() is VaultItemListingsAction.FolderClick -> handleFolderClick(action) is VaultItemListingsAction.CollectionClick -> handleCollectionClick(action) @@ -344,7 +353,13 @@ class VaultItemListingViewModel @Inject constructor( } if (activeAccount.vaultUnlockType == VaultUnlockType.PIN) { - // TODO: https://bitwarden.atlassian.net/browse/PM-9682 + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2PinPrompt( + selectedCipherId = selectedCipherId, + ), + ) + } } else if (activeAccount.hasMasterPassword) { mutableStateFlow.update { it.copy( @@ -373,10 +388,6 @@ class VaultItemListingViewModel @Inject constructor( } } - private fun handleDismissFido2PasswordVerificationDialogClick() { - showFido2ErrorDialog() - } - private fun handleRetryFido2PasswordVerificationClick( action: VaultItemListingsAction.RetryFido2PasswordVerificationClick, ) { @@ -389,6 +400,36 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun handlePinFido2VerificationSubmit( + action: VaultItemListingsAction.PinFido2VerificationSubmit, + ) { + viewModelScope.launch { + val result = authRepository.validatePin(action.pin) + sendAction( + VaultItemListingsAction.Internal.ValidateFido2PinResultReceive( + result = result, + selectedCipherId = action.selectedCipherId, + ), + ) + } + } + + private fun handleRetryFido2PinVerificationClick( + action: VaultItemListingsAction.RetryFido2PinVerificationClick, + ) { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2PinPrompt( + selectedCipherId = action.selectedCipherId, + ), + ) + } + } + + private fun handleDismissFido2VerificationDialogClick() { + showFido2ErrorDialog() + } + private fun handleCopySendUrlClick(action: ListingItemOverflowAction.SendAction.CopyUrlClick) { clipboardManager.setText(text = action.sendUrl) } @@ -659,7 +700,7 @@ class VaultItemListingViewModel @Inject constructor( } private fun handleDismissDialogClick() { - mutableStateFlow.update { it.copy(dialogState = null) } + clearDialogState() } private fun handleDismissFido2ErrorDialogClick() { @@ -791,6 +832,10 @@ class VaultItemListingViewModel @Inject constructor( handleValidateFido2PasswordResultReceive(action) } + is VaultItemListingsAction.Internal.ValidateFido2PinResultReceive -> { + handleValidateFido2PinResultReceive(action) + } + is VaultItemListingsAction.Internal.PolicyUpdateReceive -> { handlePolicyUpdateReceive(action) } @@ -829,7 +874,7 @@ class VaultItemListingViewModel @Inject constructor( } DeleteSendResult.Success -> { - mutableStateFlow.update { it.copy(dialogState = null) } + clearDialogState() sendEvent(VaultItemListingEvent.ShowToast(R.string.send_deleted.asText())) } } @@ -854,7 +899,7 @@ class VaultItemListingViewModel @Inject constructor( } is RemovePasswordSendResult.Success -> { - mutableStateFlow.update { it.copy(dialogState = null) } + clearDialogState() sendEvent( VaultItemListingEvent.ShowToast( text = R.string.send_password_removed.asText(), @@ -908,7 +953,7 @@ class VaultItemListingViewModel @Inject constructor( private fun handleMasterPasswordRepromptResultReceive( action: VaultItemListingsAction.Internal.ValidatePasswordResultReceive, ) { - mutableStateFlow.update { it.copy(dialogState = null) } + clearDialogState() when (val result = action.result) { ValidatePasswordResult.Error -> { @@ -962,7 +1007,7 @@ class VaultItemListingViewModel @Inject constructor( private fun handleValidateFido2PasswordResultReceive( action: VaultItemListingsAction.Internal.ValidateFido2PasswordResultReceive, ) { - mutableStateFlow.update { it.copy(dialogState = null) } + clearDialogState() when (action.result) { ValidatePasswordResult.Error -> { @@ -970,39 +1015,77 @@ class VaultItemListingViewModel @Inject constructor( } is ValidatePasswordResult.Success -> { - if (!action.result.isValid) { - fido2CredentialManager.authenticationAttempts += 1 - if (fido2CredentialManager.hasAuthenticationAttemptsRemaining()) { - mutableStateFlow.update { - it.copy( - dialogState = VaultItemListingState - .DialogState - .Fido2MasterPasswordError( - title = null, - message = R.string.invalid_master_password.asText(), - selectedCipherId = action.selectedCipherId, - ), - ) - } - } else { - showFido2ErrorDialog() - } - return + if (action.result.isValid) { + handleValidAuthentication(action.selectedCipherId) + } else { + handleInvalidAuthentication( + errorDialogState = VaultItemListingState + .DialogState + .Fido2MasterPasswordError( + title = null, + message = R.string.invalid_master_password.asText(), + selectedCipherId = action.selectedCipherId, + ), + ) } - - fido2CredentialManager.isUserVerified = true - fido2CredentialManager.authenticationAttempts = 0 - - val cipherView = getCipherViewOrNull(cipherId = action.selectedCipherId) - ?: run { - showFido2ErrorDialog() - return - } - - getRequestAndRegisterCredential(cipherView = cipherView) } } } + + private fun handleValidateFido2PinResultReceive( + action: VaultItemListingsAction.Internal.ValidateFido2PinResultReceive, + ) { + clearDialogState() + + when (action.result) { + ValidatePinResult.Error -> { + showFido2ErrorDialog() + } + + is ValidatePinResult.Success -> { + if (action.result.isValid) { + handleValidAuthentication(action.selectedCipherId) + } else { + handleInvalidAuthentication( + errorDialogState = VaultItemListingState + .DialogState + .Fido2PinError( + title = null, + message = R.string.invalid_pin.asText(), + selectedCipherId = action.selectedCipherId, + ), + ) + } + } + } + } + + private fun handleInvalidAuthentication( + errorDialogState: VaultItemListingState.DialogState, + ) { + fido2CredentialManager.authenticationAttempts += 1 + if (fido2CredentialManager.hasAuthenticationAttemptsRemaining()) { + mutableStateFlow.update { + it.copy(dialogState = errorDialogState) + } + } else { + showFido2ErrorDialog() + } + } + + private fun handleValidAuthentication(selectedCipherId: String) { + fido2CredentialManager.isUserVerified = true + fido2CredentialManager.authenticationAttempts = 0 + + val cipherView = getCipherViewOrNull(cipherId = selectedCipherId) + ?: run { + showFido2ErrorDialog() + return + } + + getRequestAndRegisterCredential(cipherView = cipherView) + } + //endregion VaultItemListing Handlers private fun vaultErrorReceive(vaultData: DataState.Error) { @@ -1068,7 +1151,7 @@ class VaultItemListingViewModel @Inject constructor( private fun handleFido2RegisterCredentialResultReceive( action: VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive, ) { - mutableStateFlow.update { it.copy(dialogState = null) } + clearDialogState() when (action.result) { is Fido2RegisterCredentialResult.Error -> { sendEvent(VaultItemListingEvent.ShowToast(R.string.an_error_has_occurred.asText())) @@ -1393,6 +1476,26 @@ data class VaultItemListingState( val message: Text, val selectedCipherId: String, ) : DialogState() + + /** + * Represents a dialog to prompt the user for their PIN as part of the FIDO 2 + * user verification flow. + */ + @Parcelize + data class Fido2PinPrompt( + val selectedCipherId: String, + ) : DialogState() + + /** + * Represents a dialog to alert the user that their PIN for the FIDO 2 user + * verification flow was incorrect and to retry. + */ + @Parcelize + data class Fido2PinError( + val title: Text?, + val message: Text, + val selectedCipherId: String, + ) : DialogState() } /** @@ -1780,9 +1883,9 @@ sealed class VaultItemListingsAction { ) : VaultItemListingsAction() /** - * Click to dismiss the FIDO 2 password verification dialog. + * Click to dismiss the FIDO 2 password or PIN verification dialog. */ - data object DismissFido2PasswordVerificationDialogClick : VaultItemListingsAction() + data object DismissFido2VerificationDialogClick : VaultItemListingsAction() /** * Click to retry the FIDO 2 password verification. @@ -1791,6 +1894,21 @@ sealed class VaultItemListingsAction { val selectedCipherId: String, ) : VaultItemListingsAction() + /** + * Click to submit the PIN for FIDO 2 verification. + */ + data class PinFido2VerificationSubmit( + val pin: String, + val selectedCipherId: String, + ) : VaultItemListingsAction() + + /** + * Click to retry the FIDO 2 PIN verification. + */ + data class RetryFido2PinVerificationClick( + val selectedCipherId: String, + ) : VaultItemListingsAction() + /** * Click the refresh button. */ @@ -1959,6 +2077,15 @@ sealed class VaultItemListingsAction { val selectedCipherId: String, ) : Internal() + /** + * Indicates that a result for verifying the user's PIN as part of the FIDO 2 + * user verification flow has been received. + */ + data class ValidateFido2PinResultReceive( + val result: ValidatePinResult, + val selectedCipherId: String, + ) : Internal() + /** * Indicates that a policy update has been received. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 1444a6560..082c6c14b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -71,6 +71,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult @@ -98,6 +99,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockOrganization import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult @@ -4646,6 +4648,168 @@ class AuthRepositoryTest { ) } + @Test + fun `validatePin returns ValidatePinResult Error when no active account found`() = runTest { + val pin = "PIN" + fakeAuthDiskSource.userState = null + + val result = repository.validatePin(pin = pin) + + assertEquals( + ValidatePinResult.Error, + result, + ) + } + + @Test + fun `validatePin returns ValidatePinResult Error when no private key found`() = runTest { + val pin = "PIN" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storePrivateKey( + userId = SINGLE_USER_STATE_1.activeUserId, + privateKey = null, + ) + + val result = repository.validatePin(pin = pin) + + assertEquals( + ValidatePinResult.Error, + result, + ) + } + + @Test + fun `validatePin returns ValidatePinResult Error when no pin protected user key found`() = + runTest { + val pin = "PIN" + val privateKey = "privateKey" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storePrivateKey( + userId = SINGLE_USER_STATE_1.activeUserId, + privateKey = privateKey, + ) + fakeAuthDiskSource.storePinProtectedUserKey( + userId = SINGLE_USER_STATE_1.activeUserId, + pinProtectedUserKey = null, + ) + + val result = repository.validatePin(pin = pin) + + assertEquals( + ValidatePinResult.Error, + result, + ) + } + + @Test + fun `validatePin returns ValidatePinResult Error when initialize crypto fails`() = runTest { + val pin = "PIN" + val privateKey = "privateKey" + val pinProtectedUserKey = "pinProtectedUserKey" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storePrivateKey( + userId = SINGLE_USER_STATE_1.activeUserId, + privateKey = privateKey, + ) + fakeAuthDiskSource.storePinProtectedUserKey( + userId = SINGLE_USER_STATE_1.activeUserId, + pinProtectedUserKey = pinProtectedUserKey, + ) + coEvery { + vaultSdkSource.initializeCrypto( + userId = SINGLE_USER_STATE_1.activeUserId, + request = any(), + ) + } returns Throwable().asFailure() + + val result = repository.validatePin(pin = pin) + + assertEquals( + ValidatePinResult.Error, + result, + ) + coVerify { + vaultSdkSource.initializeCrypto( + userId = SINGLE_USER_STATE_1.activeUserId, + request = any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `validatePin returns ValidatePinResult Success with valid false when initialize cryto returns AuthenticationError`() = + runTest { + val pin = "PIN" + val privateKey = "privateKey" + val pinProtectedUserKey = "pinProtectedUserKey" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storePrivateKey( + userId = SINGLE_USER_STATE_1.activeUserId, + privateKey = privateKey, + ) + fakeAuthDiskSource.storePinProtectedUserKey( + userId = SINGLE_USER_STATE_1.activeUserId, + pinProtectedUserKey = pinProtectedUserKey, + ) + coEvery { + vaultSdkSource.initializeCrypto( + userId = SINGLE_USER_STATE_1.activeUserId, + request = any(), + ) + } returns InitializeCryptoResult.AuthenticationError.asSuccess() + + val result = repository.validatePin(pin = pin) + + assertEquals( + ValidatePinResult.Success(isValid = false), + result, + ) + coVerify { + vaultSdkSource.initializeCrypto( + userId = SINGLE_USER_STATE_1.activeUserId, + request = any(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `validatePin returns ValidatePinResult Success with valid true when initialize cryto returns Success`() = + runTest { + val pin = "PIN" + val privateKey = "privateKey" + val pinProtectedUserKey = "pinProtectedUserKey" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storePrivateKey( + userId = SINGLE_USER_STATE_1.activeUserId, + privateKey = privateKey, + ) + fakeAuthDiskSource.storePinProtectedUserKey( + userId = SINGLE_USER_STATE_1.activeUserId, + pinProtectedUserKey = pinProtectedUserKey, + ) + coEvery { + vaultSdkSource.initializeCrypto( + userId = SINGLE_USER_STATE_1.activeUserId, + request = any(), + ) + } returns InitializeCryptoResult.Success.asSuccess() + + val result = repository.validatePin(pin = pin) + + assertEquals( + ValidatePinResult.Success(isValid = true), + result, + ) + coVerify { + vaultSdkSource.initializeCrypto( + userId = SINGLE_USER_STATE_1.activeUserId, + request = any(), + ) + } + } + @Test fun `logOutFlow emission for action account should call logout on the UserLogoutManager`() { val userId = USER_ID_1 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 946a4deb6..8774ff015 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -1510,7 +1510,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { .performClick() verify { viewModel.trySendAction( - VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick, + VaultItemListingsAction.DismissFido2VerificationDialogClick, ) } @@ -1520,7 +1520,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { .performClick() verify { viewModel.trySendAction( - VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick, + VaultItemListingsAction.DismissFido2VerificationDialogClick, ) } @@ -1578,6 +1578,100 @@ class VaultItemListingScreenTest : BaseComposeTest() { } } + @Test + fun `fido2 pin prompt dialog should display and function according to state`() { + val selectedCipherId = "selectedCipherId" + val dialogTitle = "Verify PIN" + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText(dialogTitle).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2PinPrompt( + selectedCipherId = selectedCipherId, + ), + ) + } + + composeTestRule + .onNodeWithText(dialogTitle) + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + + composeTestRule + .onAllNodesWithText(text = "Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { + viewModel.trySendAction( + VaultItemListingsAction.DismissFido2VerificationDialogClick, + ) + } + + composeTestRule + .onAllNodesWithText(text = "Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { + viewModel.trySendAction( + VaultItemListingsAction.DismissFido2VerificationDialogClick, + ) + } + + composeTestRule + .onAllNodesWithText(text = "PIN") + .filterToOne(hasAnyAncestor(isDialog())) + .performTextInput("PIN") + composeTestRule + .onAllNodesWithText(text = "Submit") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + VaultItemListingsAction.PinFido2VerificationSubmit( + pin = "PIN", + selectedCipherId = selectedCipherId, + ), + ) + } + } + + @Test + fun `fido2 pin error dialog should display and function according to state`() { + val selectedCipherId = "selectedCipherId" + val dialogMessage = "Invalid PIN. Try again." + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText(dialogMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2PinError( + title = null, + message = dialogMessage.asText(), + selectedCipherId = selectedCipherId, + ), + ) + } + + composeTestRule + .onNodeWithText(dialogMessage) + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + + composeTestRule + .onAllNodesWithText(text = "Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { + viewModel.trySendAction( + VaultItemListingsAction.RetryFido2PinVerificationClick( + selectedCipherId = selectedCipherId, + ), + ) + } + } + @Test fun `CompleteFido2Registration event should call Fido2CompletionManager with result`() { val result = Fido2RegisterCredentialResult.Success("mockResponse") diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index fae3f25f5..92883b6c9 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -9,7 +9,9 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult 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 @@ -2385,6 +2387,33 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } + @Test + fun `UserVerificationNotSupported should display Fido2PinPrompt when user has pin`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf( + DEFAULT_ACCOUNT.copy( + vaultUnlockType = VaultUnlockType.PIN, + ), + ), + ) + + viewModel.trySendAction( + VaultItemListingsAction.UserVerificationNotSupported( + selectedCipherId = selectedCipherId, + ), + ) + + verify { fido2CredentialManager.isUserVerified = false } + assertEquals( + VaultItemListingState.DialogState.Fido2PinPrompt( + selectedCipherId = selectedCipherId, + ), + viewModel.stateFlow.value.dialogState, + ) + } + @Suppress("MaxLineLength") @Test fun `UserVerificationNotSupported should display Fido2MasterPasswordPrompt when user has password but no pin`() { @@ -2558,23 +2587,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } - @Test - fun `DismissFido2PasswordVerificationDialogClick should display Fido2ErrorDialog`() { - val viewModel = createVaultItemListingViewModel() - viewModel.trySendAction( - VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick, - ) - - assertEquals( - VaultItemListingState.DialogState.Fido2CreationFail( - title = R.string.an_error_has_occurred.asText(), - message = R.string.passkey_operation_failed_because_user_could_not_be_verified - .asText(), - ), - viewModel.stateFlow.value.dialogState, - ) - } - @Test fun `RetryFido2PasswordVerificationClick should display Fido2MasterPasswordPrompt`() { val viewModel = createVaultItemListingViewModel() @@ -2594,6 +2606,193 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `PinFido2VerificationSubmit should display Fido2ErrorDialog when pin verification fails`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + val pin = "PIN" + coEvery { + authRepository.validatePin(pin = pin) + } returns ValidatePinResult.Error + + viewModel.trySendAction( + VaultItemListingsAction.PinFido2VerificationSubmit( + pin = pin, + selectedCipherId = selectedCipherId, + ), + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + coVerify { + authRepository.validatePin(pin = pin) + } + } + + @Suppress("MaxLineLength") + @Test + fun `PinFido2VerificationSubmit should display Fido2PinError when user has retries remaining`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + val pin = "PIN" + coEvery { + authRepository.validatePin(pin = pin) + } returns ValidatePinResult.Success(isValid = false) + + viewModel.trySendAction( + VaultItemListingsAction.PinFido2VerificationSubmit( + pin = pin, + selectedCipherId = selectedCipherId, + ), + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2PinError( + title = null, + message = R.string.invalid_pin.asText(), + selectedCipherId = selectedCipherId, + ), + viewModel.stateFlow.value.dialogState, + ) + coVerify { + authRepository.validatePin(pin = pin) + } + } + + @Suppress("MaxLineLength") + @Test + fun `PinFido2VerificationSubmit should display Fido2ErrorDialog when user has no retries remaining`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + val pin = "PIN" + every { fido2CredentialManager.hasAuthenticationAttemptsRemaining() } returns false + coEvery { + authRepository.validatePin(pin = pin) + } returns ValidatePinResult.Success(isValid = false) + + viewModel.trySendAction( + VaultItemListingsAction.PinFido2VerificationSubmit( + pin = pin, + selectedCipherId = selectedCipherId, + ), + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + coVerify { + authRepository.validatePin(pin = pin) + } + } + + @Test + fun `PinFido2VerificationSubmit should display Fido2ErrorDialog when cipher not found`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + val pin = "PIN" + coEvery { + authRepository.validatePin(pin = pin) + } returns ValidatePinResult.Success(isValid = true) + + viewModel.trySendAction( + VaultItemListingsAction.PinFido2VerificationSubmit( + pin = pin, + selectedCipherId = selectedCipherId, + ), + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + coVerify { + authRepository.validatePin(pin = pin) + } + } + + @Suppress("MaxLineLength") + @Test + fun `PinFido2VerificationSubmit should register credential when pin authenticated successfully`() { + setupMockUri() + val viewModel = createVaultItemListingViewModel() + val cipherView = createMockCipherView(number = 1) + val selectedCipherId = cipherView.id ?: "" + val pin = "PIN" + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView), + collectionViewList = emptyList(), + folderViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + coEvery { + authRepository.validatePin(pin = pin) + } returns ValidatePinResult.Success(isValid = true) + + viewModel.trySendAction( + VaultItemListingsAction.PinFido2VerificationSubmit( + pin = pin, + selectedCipherId = selectedCipherId, + ), + ) + coVerify { + authRepository.validatePin(pin = pin) + } + } + + @Test + fun `RetryFido2PinVerificationClick should display FidoPinPrompt`() { + val viewModel = createVaultItemListingViewModel() + val selectedCipherId = "selectedCipherId" + + viewModel.trySendAction( + VaultItemListingsAction.RetryFido2PinVerificationClick( + selectedCipherId = selectedCipherId, + ), + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2PinPrompt( + selectedCipherId = selectedCipherId, + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Test + fun `DismissFido2VerificationDialogClick should display Fido2ErrorDialog`() { + val viewModel = createVaultItemListingViewModel() + viewModel.trySendAction( + VaultItemListingsAction.DismissFido2VerificationDialogClick, + ) + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = R.string.passkey_operation_failed_because_user_could_not_be_verified + .asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + @Test fun `ConfirmOverwriteExistingPasskeyClick should check if user is verified`() = runTest {