From 1e5bee2917ee2c1da2a87dc7c3b21db94f0b327d Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 6 Aug 2024 11:43:12 -0400 Subject: [PATCH] [PM-10644] Re-prompt master password for protected passkeys (#3682) --- .../itemlisting/VaultItemListingViewModel.kt | 20 ++- .../VaultItemListingViewModelTest.kt | 118 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) 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 29b35ef3a..f1e26b1aa 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 @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.fido.Fido2CredentialAutofillView +import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -1413,7 +1414,24 @@ class VaultItemListingViewModel @Inject constructor( showFido2ErrorDialog() return } - verifyUserAndAuthenticateCredential(request, selectedCipher) + + if (state.hasMasterPassword && + selectedCipher.reprompt == CipherRepromptType.PASSWORD + ) { + repromptMasterPasswordForFido2Assertion(request.cipherId) + } else { + verifyUserAndAuthenticateCredential(request, selectedCipher) + } + } + } + + private fun repromptMasterPasswordForFido2Assertion(cipherId: String) { + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2MasterPasswordPrompt( + selectedCipherId = cipherId, + ), + ) } } 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 de32616dc..b61cbce56 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -4,6 +4,7 @@ import android.content.pm.SigningInfo import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -2697,6 +2698,123 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `Fido2AssertionRequest should prompt for master password when passkey is protected and user has master password`() { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { fido2CredentialManager.isUserVerified } returns true + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.PREFERRED, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf( + createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + repromptType = CipherRepromptType.PASSWORD, + ), + ) + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions(number = 1) + every { authRepository.activeUserId } returns null + + val viewModel = createVaultItemListingViewModel() + + assertEquals( + VaultItemListingState.DialogState.Fido2MasterPasswordPrompt( + selectedCipherId = mockAssertionRequest.cipherId!!, + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2AssertionRequest should not re-prompt master password when user does not have a master password`() = + runTest { + val mockAssertionRequest = createMockFido2CredentialAssertionRequest(number = 1) + .copy(cipherId = "mockId-1") + val mockFido2CredentialList = createMockSdkFido2CredentialList(number = 1) + val mockCipherView = createMockCipherView( + number = 1, + fido2Credentials = mockFido2CredentialList, + repromptType = CipherRepromptType.PASSWORD, + ) + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf( + DEFAULT_ACCOUNT.copy( + vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, + + trustedDevice = UserState.TrustedDevice( + isDeviceTrusted = true, + hasMasterPassword = false, + hasAdminApproval = true, + hasLoginApprovingDevice = true, + hasResetPasswordPermission = true, + ), + ), + ), + ) + + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Assertion( + mockAssertionRequest, + ) + every { fido2CredentialManager.isUserVerified } returns true + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions( + number = 1, + userVerificationRequirement = UserVerificationRequirement.PREFERRED, + ) + every { + vaultRepository + .ciphersStateFlow + .value + .data + } returns listOf(mockCipherView) + every { + fido2CredentialManager.getPasskeyAssertionOptionsOrNull( + mockAssertionRequest.requestJson, + ) + } returns createMockPasskeyAssertionOptions(number = 1) + coEvery { + fido2CredentialManager.authenticateFido2Credential( + DEFAULT_USER_STATE.activeUserId, + mockAssertionRequest, + mockCipherView, + ) + } returns Fido2CredentialAssertionResult.Success("responseJson") + + createVaultItemListingViewModel() + + coVerify { + fido2CredentialManager.authenticateFido2Credential( + userId = any(), + request = any(), + selectedCipherView = any(), + ) + } + } + @Suppress("MaxLineLength") @Test fun `UserVerificationLockout should display Fido2ErrorDialog and set isUserVerified to false`() {