PM-9682: Verify with PIN on item listing (#3600)

This commit is contained in:
Shannon Draeker 2024-07-23 10:53:44 -06:00 committed by GitHub
parent 779cd1356a
commit 7cf7536857
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 853 additions and 74 deletions

View file

@ -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.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult 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.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.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
@ -342,6 +343,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/ */
suspend fun validatePassword(password: String): ValidatePasswordResult 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 * Validates the given [password] against the master password
* policies for the current user. * policies for the current user.

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.auth.repository
import android.os.SystemClock import android.os.SystemClock
import com.bitwarden.core.AuthRequestMethod import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.crypto.HashPurpose import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource 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.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations 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.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.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType 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.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.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson 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.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.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult 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( override suspend fun validatePasswordAgainstPolicies(
password: String, password: String,
): Boolean = passwordPolicies ): Boolean = passwordPolicies

View file

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

View file

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

View file

@ -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.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog 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.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.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter 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) { onRetryFido2PasswordVerification = remember(viewModel) {
{ {
viewModel.trySendAction( 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( VaultItemListingScaffold(
@ -222,6 +240,7 @@ fun VaultItemListingScreen(
) )
} }
@Suppress("LongMethod")
@Composable @Composable
private fun VaultItemListingDialogs( private fun VaultItemListingDialogs(
dialogState: VaultItemListingState.DialogState?, dialogState: VaultItemListingState.DialogState?,
@ -229,8 +248,10 @@ private fun VaultItemListingDialogs(
onDismissFido2ErrorDialog: () -> Unit, onDismissFido2ErrorDialog: () -> Unit,
onConfirmOverwriteExistingPasskey: (cipherViewId: String) -> Unit, onConfirmOverwriteExistingPasskey: (cipherViewId: String) -> Unit,
onSubmitMasterPasswordFido2Verification: (password: String, cipherId: String) -> Unit, onSubmitMasterPasswordFido2Verification: (password: String, cipherId: String) -> Unit,
onDismissFido2PasswordVerification: () -> Unit,
onRetryFido2PasswordVerification: (cipherViewId: String) -> Unit, onRetryFido2PasswordVerification: (cipherViewId: String) -> Unit,
onSubmitPinFido2Verification: (pin: String, cipherId: String) -> Unit,
onRetryFido2PinVerification: (cipherViewId: String) -> Unit,
onDismissFido2Verification: () -> Unit,
) { ) {
when (dialogState) { when (dialogState) {
is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog( is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog(
@ -268,7 +289,7 @@ private fun VaultItemListingDialogs(
dialogState.selectedCipherId, 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 null -> Unit
} }
} }

View file

@ -8,6 +8,7 @@ import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository 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.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.VaultUnlockType
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager 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.Fido2CredentialRequest
@ -189,14 +190,22 @@ class VaultItemListingViewModel @Inject constructor(
handleMasterPasswordFido2VerificationSubmit(action) handleMasterPasswordFido2VerificationSubmit(action)
} }
VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick -> {
handleDismissFido2PasswordVerificationDialogClick()
}
is VaultItemListingsAction.RetryFido2PasswordVerificationClick -> { is VaultItemListingsAction.RetryFido2PasswordVerificationClick -> {
handleRetryFido2PasswordVerificationClick(action) handleRetryFido2PasswordVerificationClick(action)
} }
is VaultItemListingsAction.PinFido2VerificationSubmit -> {
handlePinFido2VerificationSubmit(action)
}
is VaultItemListingsAction.RetryFido2PinVerificationClick -> {
handleRetryFido2PinVerificationClick(action)
}
VaultItemListingsAction.DismissFido2VerificationDialogClick -> {
handleDismissFido2VerificationDialogClick()
}
is VaultItemListingsAction.BackClick -> handleBackClick() is VaultItemListingsAction.BackClick -> handleBackClick()
is VaultItemListingsAction.FolderClick -> handleFolderClick(action) is VaultItemListingsAction.FolderClick -> handleFolderClick(action)
is VaultItemListingsAction.CollectionClick -> handleCollectionClick(action) is VaultItemListingsAction.CollectionClick -> handleCollectionClick(action)
@ -344,7 +353,13 @@ class VaultItemListingViewModel @Inject constructor(
} }
if (activeAccount.vaultUnlockType == VaultUnlockType.PIN) { 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) { } else if (activeAccount.hasMasterPassword) {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
@ -373,10 +388,6 @@ class VaultItemListingViewModel @Inject constructor(
} }
} }
private fun handleDismissFido2PasswordVerificationDialogClick() {
showFido2ErrorDialog()
}
private fun handleRetryFido2PasswordVerificationClick( private fun handleRetryFido2PasswordVerificationClick(
action: VaultItemListingsAction.RetryFido2PasswordVerificationClick, 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) { private fun handleCopySendUrlClick(action: ListingItemOverflowAction.SendAction.CopyUrlClick) {
clipboardManager.setText(text = action.sendUrl) clipboardManager.setText(text = action.sendUrl)
} }
@ -659,7 +700,7 @@ class VaultItemListingViewModel @Inject constructor(
} }
private fun handleDismissDialogClick() { private fun handleDismissDialogClick() {
mutableStateFlow.update { it.copy(dialogState = null) } clearDialogState()
} }
private fun handleDismissFido2ErrorDialogClick() { private fun handleDismissFido2ErrorDialogClick() {
@ -791,6 +832,10 @@ class VaultItemListingViewModel @Inject constructor(
handleValidateFido2PasswordResultReceive(action) handleValidateFido2PasswordResultReceive(action)
} }
is VaultItemListingsAction.Internal.ValidateFido2PinResultReceive -> {
handleValidateFido2PinResultReceive(action)
}
is VaultItemListingsAction.Internal.PolicyUpdateReceive -> { is VaultItemListingsAction.Internal.PolicyUpdateReceive -> {
handlePolicyUpdateReceive(action) handlePolicyUpdateReceive(action)
} }
@ -829,7 +874,7 @@ class VaultItemListingViewModel @Inject constructor(
} }
DeleteSendResult.Success -> { DeleteSendResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) } clearDialogState()
sendEvent(VaultItemListingEvent.ShowToast(R.string.send_deleted.asText())) sendEvent(VaultItemListingEvent.ShowToast(R.string.send_deleted.asText()))
} }
} }
@ -854,7 +899,7 @@ class VaultItemListingViewModel @Inject constructor(
} }
is RemovePasswordSendResult.Success -> { is RemovePasswordSendResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) } clearDialogState()
sendEvent( sendEvent(
VaultItemListingEvent.ShowToast( VaultItemListingEvent.ShowToast(
text = R.string.send_password_removed.asText(), text = R.string.send_password_removed.asText(),
@ -908,7 +953,7 @@ class VaultItemListingViewModel @Inject constructor(
private fun handleMasterPasswordRepromptResultReceive( private fun handleMasterPasswordRepromptResultReceive(
action: VaultItemListingsAction.Internal.ValidatePasswordResultReceive, action: VaultItemListingsAction.Internal.ValidatePasswordResultReceive,
) { ) {
mutableStateFlow.update { it.copy(dialogState = null) } clearDialogState()
when (val result = action.result) { when (val result = action.result) {
ValidatePasswordResult.Error -> { ValidatePasswordResult.Error -> {
@ -962,7 +1007,7 @@ class VaultItemListingViewModel @Inject constructor(
private fun handleValidateFido2PasswordResultReceive( private fun handleValidateFido2PasswordResultReceive(
action: VaultItemListingsAction.Internal.ValidateFido2PasswordResultReceive, action: VaultItemListingsAction.Internal.ValidateFido2PasswordResultReceive,
) { ) {
mutableStateFlow.update { it.copy(dialogState = null) } clearDialogState()
when (action.result) { when (action.result) {
ValidatePasswordResult.Error -> { ValidatePasswordResult.Error -> {
@ -970,39 +1015,77 @@ class VaultItemListingViewModel @Inject constructor(
} }
is ValidatePasswordResult.Success -> { is ValidatePasswordResult.Success -> {
if (!action.result.isValid) { if (action.result.isValid) {
fido2CredentialManager.authenticationAttempts += 1 handleValidAuthentication(action.selectedCipherId)
if (fido2CredentialManager.hasAuthenticationAttemptsRemaining()) { } else {
mutableStateFlow.update { handleInvalidAuthentication(
it.copy( errorDialogState = VaultItemListingState
dialogState = VaultItemListingState .DialogState
.DialogState .Fido2MasterPasswordError(
.Fido2MasterPasswordError( title = null,
title = null, message = R.string.invalid_master_password.asText(),
message = R.string.invalid_master_password.asText(), selectedCipherId = action.selectedCipherId,
selectedCipherId = action.selectedCipherId, ),
), )
)
}
} else {
showFido2ErrorDialog()
}
return
} }
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 //endregion VaultItemListing Handlers
private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) { private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) {
@ -1068,7 +1151,7 @@ class VaultItemListingViewModel @Inject constructor(
private fun handleFido2RegisterCredentialResultReceive( private fun handleFido2RegisterCredentialResultReceive(
action: VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive, action: VaultItemListingsAction.Internal.Fido2RegisterCredentialResultReceive,
) { ) {
mutableStateFlow.update { it.copy(dialogState = null) } clearDialogState()
when (action.result) { when (action.result) {
is Fido2RegisterCredentialResult.Error -> { is Fido2RegisterCredentialResult.Error -> {
sendEvent(VaultItemListingEvent.ShowToast(R.string.an_error_has_occurred.asText())) sendEvent(VaultItemListingEvent.ShowToast(R.string.an_error_has_occurred.asText()))
@ -1393,6 +1476,26 @@ data class VaultItemListingState(
val message: Text, val message: Text,
val selectedCipherId: String, val selectedCipherId: String,
) : DialogState() ) : 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() ) : 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. * Click to retry the FIDO 2 password verification.
@ -1791,6 +1894,21 @@ sealed class VaultItemListingsAction {
val selectedCipherId: String, val selectedCipherId: String,
) : VaultItemListingsAction() ) : 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. * Click the refresh button.
*/ */
@ -1959,6 +2077,15 @@ sealed class VaultItemListingsAction {
val selectedCipherId: String, val selectedCipherId: String,
) : Internal() ) : 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. * Indicates that a policy update has been received.
*/ */

View file

@ -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.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations 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.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.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult 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.createMockOrganization
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy 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.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.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult 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 @Test
fun `logOutFlow emission for action account should call logout on the UserLogoutManager`() { fun `logOutFlow emission for action account should call logout on the UserLogoutManager`() {
val userId = USER_ID_1 val userId = USER_ID_1

View file

@ -1510,7 +1510,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
.performClick() .performClick()
verify { verify {
viewModel.trySendAction( viewModel.trySendAction(
VaultItemListingsAction.DismissFido2PasswordVerificationDialogClick, VaultItemListingsAction.DismissFido2VerificationDialogClick,
) )
} }
@ -1520,7 +1520,7 @@ class VaultItemListingScreenTest : BaseComposeTest() {
.performClick() .performClick()
verify { verify {
viewModel.trySendAction( 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 @Test
fun `CompleteFido2Registration event should call Fido2CompletionManager with result`() { fun `CompleteFido2Registration event should call Fido2CompletionManager with result`() {
val result = Fido2RegisterCredentialResult.Success("mockResponse") val result = Fido2RegisterCredentialResult.Success("mockResponse")

View file

@ -9,7 +9,9 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository 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.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState 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.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.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest 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.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") @Suppress("MaxLineLength")
@Test @Test
fun `UserVerificationNotSupported should display Fido2MasterPasswordPrompt when user has password but no pin`() { 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 @Test
fun `RetryFido2PasswordVerificationClick should display Fido2MasterPasswordPrompt`() { fun `RetryFido2PasswordVerificationClick should display Fido2MasterPasswordPrompt`() {
val viewModel = createVaultItemListingViewModel() 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 @Test
fun `ConfirmOverwriteExistingPasskeyClick should check if user is verified`() = fun `ConfirmOverwriteExistingPasskeyClick should check if user is verified`() =
runTest { runTest {