[PM-43768] fix: preserve biometric unlock after transient Keystore authorization failure (#7399)

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-10-09 18:50:03 +00:00
committed by GitHub
co-authored by Matt Van Horn Claude Opus 5.5
parent 807b5e0f9b
commit 2cb8b8fa79
12 changed files with 723 additions and 24 deletions
@@ -13,6 +13,7 @@ fun VaultUnlockError.toLoginErrorResult(): LoginResult.Error = when (this) {
}
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.BiometricKeystoreAuthorizationError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> LoginResult.Error(error = this.error)
@@ -66,6 +66,7 @@ class AuthenticatorBridgeRepositoryImpl(
when (vaultUnlockResult) {
is VaultUnlockResult.AuthenticationError,
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.BiometricKeystoreAuthorizationError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> {
@@ -1,5 +1,9 @@
package com.x8bit.bitwarden.data.platform.util
import android.os.Build
import android.security.KeyStoreException
import androidx.annotation.RequiresApi
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.exception.LocalNetworkAccessException
@@ -13,3 +17,36 @@ val Throwable.userFriendlyMessage: String?
is CookieRedirectException -> message
else -> null
}
/**
* Returns true when this [Throwable] or one of its causes is an Android Keystore exception that
* reports [KeyStoreException.ERROR_USER_AUTHENTICATION_REQUIRED].
*
* The public numeric-error API is only queried on API 33+.
*/
fun Throwable.isKeystoreUserAuthenticationRequired(): Boolean {
if (!isBuildVersionAtLeast(Build.VERSION_CODES.TIRAMISU)) {
return false
}
return hasUserAuthenticationRequiredKeystoreCause()
}
/**
* Returns true when this [Throwable] or one of its causes is a [KeyStoreException] reporting
* [KeyStoreException.ERROR_USER_AUTHENTICATION_REQUIRED].
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun Throwable.hasUserAuthenticationRequiredKeystoreCause(): Boolean {
val seen = mutableSetOf<Throwable>()
var current: Throwable? = this
while (current != null && seen.add(current)) {
if (
current is KeyStoreException &&
current.numericErrorCode == KeyStoreException.ERROR_USER_AUTHENTICATION_REQUIRED
) {
return true
}
current = current.cause
}
return false
}
@@ -49,6 +49,7 @@ import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList
import kotlinx.coroutines.CoroutineScope
@@ -302,7 +303,7 @@ internal class VaultRepositoryImpl(
.decodeToString()
} catch (e: GeneralSecurityException) {
Timber.w(e, "unlockVaultWithBiometrics failed when decrypting biometrics key")
return VaultUnlockResult.BiometricDecodingError(error = e)
return e.toVaultUnlockResult()
}
}
?: biometricsKey
@@ -316,7 +317,7 @@ internal class VaultRepositoryImpl(
.toString(Charsets.ISO_8859_1)
} catch (e: GeneralSecurityException) {
Timber.w(e, "unlockVaultWithBiometrics failed to migrate the user to IV encryption")
return VaultUnlockResult.BiometricDecodingError(error = e)
return e.toVaultUnlockResult()
}
} else {
null
@@ -25,6 +25,13 @@ sealed class VaultUnlockResult {
override val error: Throwable?,
) : VaultUnlockResult(), VaultUnlockError
/**
* Biometric cipher is missing its Keystore authorization.
*/
data class BiometricKeystoreAuthorizationError(
override val error: Throwable,
) : VaultUnlockResult(), VaultUnlockError
/**
* Unable to access user state information.
*/
@@ -1,7 +1,9 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.x8bit.bitwarden.data.platform.util.isKeystoreUserAuthenticationRequired
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import java.security.GeneralSecurityException
/**
* Transform a [InitializeCryptoResult] to [VaultUnlockResult].
@@ -17,3 +19,16 @@ fun InitializeCryptoResult.toVaultUnlockResult(): VaultUnlockResult =
InitializeCryptoResult.Success -> VaultUnlockResult.Success
}
/**
* Maps a biometric cipher security failure to a [VaultUnlockResult].
*
* A Keystore authorization miss is reported separately so callers can retry before clearing
* biometrics. All other security failures remain decoding errors.
*/
fun GeneralSecurityException.toVaultUnlockResult(): VaultUnlockResult =
if (isKeystoreUserAuthenticationRequired()) {
VaultUnlockResult.BiometricKeystoreAuthorizationError(error = this)
} else {
VaultUnlockResult.BiometricDecodingError(error = this)
}
@@ -48,6 +48,7 @@ import javax.crypto.Cipher
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val KEYSTORE_AUTH_FAILURES_BEFORE_CLEAR = 2
/**
* Manages application state for the initial vault unlock screen.
@@ -365,6 +366,10 @@ class VaultUnlockViewModel @Inject constructor(
}
}
is VaultUnlockResult.BiometricKeystoreAuthorizationError -> {
handleBiometricKeystoreAuthorizationError(result)
}
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> {
@@ -380,15 +385,54 @@ class VaultUnlockViewModel @Inject constructor(
}
}
VaultUnlockResult.Success -> {
if (specialCircumstanceManager.specialCircumstance == null) {
specialCircumstanceManager.specialCircumstance =
appResumeManager.getResumeSpecialCircumstance()
}
VaultUnlockResult.Success -> handleVaultUnlockSuccess()
}
}
mutableStateFlow.update { it.copy(dialog = null) }
// Don't do anything, we'll navigate to the right place.
private fun handleVaultUnlockSuccess() {
if (specialCircumstanceManager.specialCircumstance == null) {
specialCircumstanceManager.specialCircumstance =
appResumeManager.getResumeSpecialCircumstance()
}
mutableStateFlow.update {
it.copy(
dialog = null,
keystoreAuthorizationFailureCount = 0,
)
}
// Don't do anything, we'll navigate to the right place.
}
private fun handleBiometricKeystoreAuthorizationError(
result: VaultUnlockResult.BiometricKeystoreAuthorizationError,
) {
val failureCount = state.keystoreAuthorizationFailureCount + 1
if (failureCount >= KEYSTORE_AUTH_FAILURES_BEFORE_CLEAR) {
authRepository.clearBiometrics(userId = state.userId)
mutableStateFlow.update {
it.copy(
isBiometricsValid = false,
keystoreAuthorizationFailureCount = failureCount,
dialog = VaultUnlockState.VaultUnlockDialog.Error(
title = BitwardenString.biometrics_failed.asText(),
message = BitwardenString.biometrics_decoding_failure.asText(),
throwable = result.error,
),
)
}
return
}
mutableStateFlow.update {
it.copy(
keystoreAuthorizationFailureCount = failureCount,
dialog = VaultUnlockState.VaultUnlockDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
throwable = result.error,
),
)
}
}
@@ -427,6 +471,11 @@ class VaultUnlockViewModel @Inject constructor(
vaultUnlockType = userState.activeAccount.vaultUnlockType,
input = "",
hasMasterPassword = userState.activeAccount.hasMasterPassword,
keystoreAuthorizationFailureCount = if (it.userId != userState.activeUserId) {
0
} else {
it.keystoreAuthorizationFailureCount
},
)
}
@@ -467,6 +516,7 @@ data class VaultUnlockState(
val createCredentialRequest: CreateCredentialRequest? = null,
private val hasMasterPassword: Boolean,
val isFromLockFlow: Boolean,
val keystoreAuthorizationFailureCount: Int = 0,
) : Parcelable {
/**
@@ -298,6 +298,7 @@ class VerifyPasswordViewModel @Inject constructor(
is VaultUnlockResult.InvalidStateError,
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.BiometricKeystoreAuthorizationError,
is VaultUnlockResult.GenericError,
-> {
showGenericErrorDialog(throwable = action.vaultUnlockResult.error)
@@ -36,10 +36,14 @@ class LoginResultExtensionsTest {
val genericErrorResult = VaultUnlockResult.GenericError(error = error).toLoginErrorResult()
val biometricErrorResult =
VaultUnlockResult.BiometricDecodingError(error = error).toLoginErrorResult()
val keystoreAuthorizationResult = VaultUnlockResult
.BiometricKeystoreAuthorizationError(error = error)
.toLoginErrorResult()
val expectedResult = LoginResult.Error(error = error)
assertEquals(expectedResult, invalidStateResult)
assertEquals(expectedResult, genericErrorResult)
assertEquals(expectedResult, biometricErrorResult)
assertEquals(expectedResult, keystoreAuthorizationResult)
}
}
@@ -1,14 +1,37 @@
package com.x8bit.bitwarden.data.platform.util
import android.os.Build
import android.security.KeyStoreException
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.exception.LocalNetworkAccessException
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.IOException
import javax.crypto.IllegalBlockSizeException
class ThrowableExtensionsTest {
@BeforeEach
fun setup() {
mockkStatic(::isBuildVersionAtLeast)
}
@AfterEach
fun tearDown() {
unmockkStatic(::isBuildVersionAtLeast)
}
@Test
fun `userFriendlyMessage should return message for CookieRedirectException`() {
val message = "Your request was interrupted because the app needed to " +
@@ -41,4 +64,110 @@ class ThrowableExtensionsTest {
val exception = RuntimeException("runtime error")
assertNull(exception.userFriendlyMessage)
}
@Suppress("MaxLineLength")
@Test
fun `isKeystoreUserAuthenticationRequired should return true when the cause reports user authentication required on API 33`() {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
val (error, _) = createUserAuthenticationRequiredError()
assertTrue(error.isKeystoreUserAuthenticationRequired())
}
@Suppress("MaxLineLength")
@Test
fun `isKeystoreUserAuthenticationRequired should return true for a nested user authentication required cause`() {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
val (error, _) = createUserAuthenticationRequiredError(additionalWrapperCount = 2)
assertTrue(error.isKeystoreUserAuthenticationRequired())
}
@Suppress("MaxLineLength")
@Test
fun `isKeystoreUserAuthenticationRequired should return true when the throwable itself is the keystore exception`() {
mockBuildVersion(sdkInt = Build.VERSION_CODES.BAKLAVA)
val keyStoreException = mockKeyStoreException(
numericErrorCode = KeyStoreException.ERROR_USER_AUTHENTICATION_REQUIRED,
)
assertTrue(keyStoreException.isKeystoreUserAuthenticationRequired())
}
@Suppress("MaxLineLength")
@Test
fun `isKeystoreUserAuthenticationRequired should return false for a nonmatching numeric code`() {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
val keyStoreException = mockKeyStoreException(
numericErrorCode = KeyStoreException.ERROR_KEY_CORRUPTED,
)
val error = wrapKeyStoreException(keyStoreException = keyStoreException)
assertFalse(error.isKeystoreUserAuthenticationRequired())
}
@Test
fun `isKeystoreUserAuthenticationRequired should return false when the cause is missing`() {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
assertFalse(IllegalBlockSizeException().isKeystoreUserAuthenticationRequired())
}
@Suppress("MaxLineLength")
@Test
fun `isKeystoreUserAuthenticationRequired should return false below API 33 without querying the numeric code`() {
mockBuildVersion(sdkInt = Build.VERSION_CODES.S_V2)
val (error, keyStoreException) = createUserAuthenticationRequiredError()
assertFalse(error.isKeystoreUserAuthenticationRequired())
verify(exactly = 0) { keyStoreException.numericErrorCode }
}
@Suppress("MaxLineLength")
@Test
fun `isKeystoreUserAuthenticationRequired should return false and stop when the cause chain cycles`() {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
val keyStoreException = mockKeyStoreException(
numericErrorCode = KeyStoreException.ERROR_KEY_CORRUPTED,
)
val wrapper = IllegalBlockSizeException()
every { keyStoreException.cause } returns wrapper
wrapper.initCause(keyStoreException)
assertFalse(wrapper.isKeystoreUserAuthenticationRequired())
}
private fun mockBuildVersion(sdkInt: Int) {
every { isBuildVersionAtLeast(any()) } answers { sdkInt >= firstArg<Int>() }
}
private fun mockKeyStoreException(numericErrorCode: Int): KeyStoreException {
val keyStoreException = mockk<KeyStoreException>()
every { keyStoreException.numericErrorCode } returns numericErrorCode
every { keyStoreException.cause } returns null
return keyStoreException
}
private fun wrapKeyStoreException(
keyStoreException: KeyStoreException,
additionalWrapperCount: Int = 0,
): IllegalBlockSizeException {
var cause: Throwable = keyStoreException
repeat(additionalWrapperCount) { index ->
cause = RuntimeException("wrapper-$index", cause)
}
return IllegalBlockSizeException().apply { initCause(cause) }
}
private fun createUserAuthenticationRequiredError(
additionalWrapperCount: Int = 0,
): Pair<IllegalBlockSizeException, KeyStoreException> {
val keyStoreException = mockKeyStoreException(
numericErrorCode = KeyStoreException.ERROR_USER_AUTHENTICATION_REQUIRED,
)
return wrapKeyStoreException(
keyStoreException = keyStoreException,
additionalWrapperCount = additionalWrapperCount,
) to keyStoreException
}
}
@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.vault.repository
import android.os.Build
import android.security.KeyStoreException
import app.cash.turbine.test
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.InitUserCryptoMethod
@@ -11,6 +13,7 @@ import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.network.model.CipherTypeJson
@@ -71,8 +74,11 @@ import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkConstructor
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
@@ -82,9 +88,11 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.security.GeneralSecurityException
import java.security.InvalidKeyException
import java.time.Instant
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.IllegalBlockSizeException
@Suppress("LargeClass")
class VaultRepositoryTest {
@@ -157,12 +165,15 @@ class VaultRepositoryTest {
every {
anyConstructed<MissingPropertyException>() == any<MissingPropertyException>()
} returns true
mockkStatic(::isBuildVersionAtLeast)
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
}
@AfterEach
fun tearDown() {
unmockkConstructor(NoActiveUserException::class)
unmockkConstructor(MissingPropertyException::class)
unmockkStatic(::isBuildVersionAtLeast)
}
@Test
@@ -260,6 +271,156 @@ class VaultRepositoryTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with stored IV and Keystore user-authentication-required error on API 33 should return BiometricKeystoreAuthorizationError without unlocking or mutating biometrics`() =
runTest {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
val (error, _) = createUserAuthenticationRequiredDoFinalError()
val result = unlockVaultWithBiometricsDoFinalError(error = error)
assertEquals(
VaultUnlockResult.BiometricKeystoreAuthorizationError(error = error),
result,
)
assertEquals(
error,
(result as VaultUnlockResult.BiometricKeystoreAuthorizationError).error,
)
assertStoredBiometricsUnchanged()
verifyUnlockAndPinDerivationNeverCalled()
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with stored IV and Keystore user-authentication-required error on API 36 should return BiometricKeystoreAuthorizationError without unlocking or mutating biometrics`() =
runTest {
mockBuildVersion(sdkInt = Build.VERSION_CODES.BAKLAVA)
val (error, _) = createUserAuthenticationRequiredDoFinalError()
val result = unlockVaultWithBiometricsDoFinalError(error = error)
assertEquals(
VaultUnlockResult.BiometricKeystoreAuthorizationError(error = error),
result,
)
assertStoredBiometricsUnchanged()
verifyUnlockAndPinDerivationNeverCalled()
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with stored IV and nested Keystore user-authentication-required error should return BiometricKeystoreAuthorizationError`() =
runTest {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
val (error, _) = createUserAuthenticationRequiredDoFinalError(additionalWrapperCount = 2)
val result = unlockVaultWithBiometricsDoFinalError(error = error)
assertEquals(
VaultUnlockResult.BiometricKeystoreAuthorizationError(error = error),
result,
)
assertStoredBiometricsUnchanged()
verifyUnlockAndPinDerivationNeverCalled()
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics without IV and Keystore user-authentication-required error should return BiometricKeystoreAuthorizationError without migrating biometrics`() =
runTest {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
val (error, _) = createUserAuthenticationRequiredDoFinalError()
val result = unlockVaultWithBiometricsDoFinalError(
error = error,
initVector = null,
)
assertEquals(
VaultUnlockResult.BiometricKeystoreAuthorizationError(error = error),
result,
)
assertStoredBiometricsUnchanged(initVector = null)
verifyUnlockAndPinDerivationNeverCalled()
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with stored IV and Keystore error with a nonmatching numeric code should return BiometricDecodingError`() =
runTest {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
val keyStoreException = mockKeyStoreException(
numericErrorCode = KeyStoreException.ERROR_KEY_CORRUPTED,
)
val error = wrapKeyStoreException(keyStoreException = keyStoreException)
val result = unlockVaultWithBiometricsDoFinalError(error = error)
assertEquals(VaultUnlockResult.BiometricDecodingError(error = error), result)
assertStoredBiometricsUnchanged()
verifyUnlockAndPinDerivationNeverCalled()
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with stored IV and missing exception cause should return BiometricDecodingError`() =
runTest {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
val error = IllegalBlockSizeException()
val result = unlockVaultWithBiometricsDoFinalError(error = error)
assertEquals(VaultUnlockResult.BiometricDecodingError(error = error), result)
assertStoredBiometricsUnchanged()
verifyUnlockAndPinDerivationNeverCalled()
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with stored IV and permanently invalidated key should return BiometricDecodingError`() =
runTest {
mockBuildVersion(sdkInt = Build.VERSION_CODES.TIRAMISU)
val error = InvalidKeyException("Key permanently invalidated")
val result = unlockVaultWithBiometricsDoFinalError(error = error)
assertEquals(VaultUnlockResult.BiometricDecodingError(error = error), result)
assertStoredBiometricsUnchanged()
verifyUnlockAndPinDerivationNeverCalled()
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with stored IV and Keystore user-authentication-required error on API 29 should return BiometricDecodingError without querying the numeric code`() =
runTest {
mockBuildVersion(sdkInt = Build.VERSION_CODES.Q)
val (error, keyStoreException) = createUserAuthenticationRequiredDoFinalError()
val result = unlockVaultWithBiometricsDoFinalError(error = error)
assertEquals(VaultUnlockResult.BiometricDecodingError(error = error), result)
assertStoredBiometricsUnchanged()
verifyUnlockAndPinDerivationNeverCalled()
verify(exactly = 0) { keyStoreException.numericErrorCode }
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with stored IV and Keystore user-authentication-required error on API 32 should return BiometricDecodingError without querying the numeric code`() =
runTest {
mockBuildVersion(sdkInt = Build.VERSION_CODES.S_V2)
val (error, keyStoreException) = createUserAuthenticationRequiredDoFinalError()
val result = unlockVaultWithBiometricsDoFinalError(error = error)
assertEquals(VaultUnlockResult.BiometricDecodingError(error = error), result)
assertStoredBiometricsUnchanged()
verifyUnlockAndPinDerivationNeverCalled()
verify(exactly = 0) { keyStoreException.numericErrorCode }
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultWithBiometrics with an IV and VaultLockManager Success should store the updated key and IV and unlock for the current user and return Success`() =
@@ -1846,9 +2007,87 @@ class VaultRepositoryTest {
)
} returns unlockResult
}
private fun mockBuildVersion(sdkInt: Int) {
every { isBuildVersionAtLeast(any()) } answers { sdkInt >= firstArg<Int>() }
}
private fun mockKeyStoreException(numericErrorCode: Int): KeyStoreException {
val keyStoreException = mockk<KeyStoreException>()
every { keyStoreException.numericErrorCode } returns numericErrorCode
every { keyStoreException.cause } returns null
return keyStoreException
}
private fun wrapKeyStoreException(
keyStoreException: KeyStoreException,
additionalWrapperCount: Int = 0,
): IllegalBlockSizeException {
var cause: Throwable = keyStoreException
repeat(additionalWrapperCount) { index ->
cause = RuntimeException("wrapper-$index", cause)
}
return IllegalBlockSizeException().apply { initCause(cause) }
}
private fun createUserAuthenticationRequiredDoFinalError(
additionalWrapperCount: Int = 0,
): Pair<IllegalBlockSizeException, KeyStoreException> {
val keyStoreException = mockKeyStoreException(
numericErrorCode = KeyStoreException.ERROR_USER_AUTHENTICATION_REQUIRED,
)
return wrapKeyStoreException(
keyStoreException = keyStoreException,
additionalWrapperCount = additionalWrapperCount,
) to keyStoreException
}
private suspend fun unlockVaultWithBiometricsDoFinalError(
error: Throwable,
initVector: ByteArray? = BIOMETRIC_INIT_VECTOR,
biometricsKey: String = BIOMETRIC_UNLOCK_KEY,
): VaultUnlockResult {
val userId = MOCK_USER_STATE.activeUserId
fakeAuthDiskSource.userState = MOCK_USER_STATE
val cipher = mockk<Cipher> {
every { doFinal(any()) } throws error
}
fakeAuthDiskSource.apply {
storeUserBiometricInitVector(userId = userId, iv = initVector)
storeUserBiometricUnlockKey(userId = userId, biometricsKey = biometricsKey)
storeAccountCryptographicState(
userId = userId,
accountCryptographicState = MOCK_ACCOUNT_CRYPTOGRAPHIC_STATE,
)
}
return vaultRepository.unlockVaultWithBiometrics(cipher = cipher)
}
private fun assertStoredBiometricsUnchanged(
initVector: ByteArray? = BIOMETRIC_INIT_VECTOR,
biometricsKey: String = BIOMETRIC_UNLOCK_KEY,
) {
val userId = MOCK_USER_STATE.activeUserId
fakeAuthDiskSource.assertBiometricsKey(userId = userId, biometricsKey = biometricsKey)
fakeAuthDiskSource.assertBiometricInitVector(userId = userId, iv = initVector)
}
private fun verifyUnlockAndPinDerivationNeverCalled() {
coVerify(exactly = 0) {
vaultLockManager.unlockVault(any(), any(), any(), any(), any(), any())
}
coVerify(exactly = 0) {
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = any(),
)
}
}
//endregion Helper functions
}
private const val BIOMETRIC_UNLOCK_KEY = "asdf1234"
private val BIOMETRIC_INIT_VECTOR = byteArrayOf(2, 2)
private val MOCK_BASE_PROFILE = AccountJson.Profile(
userId = "mockId-1",
email = "email",
@@ -1111,7 +1111,215 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `on BiometricsUnlockSuccess should display error dialog on unlockVaultWithBiometrics AuthenticationError`() {
fun `on BiometricsUnlockSuccess should display error dialog on unlockVaultWithBiometrics AuthenticationError without clearing biometrics and allow retry`() =
runTest {
val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)),
)
val viewModel = createViewModel(state = initialState)
val error = Throwable("Fail")
coEvery {
vaultRepository.unlockVaultWithBiometrics(cipher = CIPHER)
} returns VaultUnlockResult.AuthenticationError(error = error)
viewModel.eventFlow.test {
assertEquals(VaultUnlockEvent.PromptForBiometrics(CIPHER), awaitItem())
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
val expectedErrorState = initialState.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
throwable = error,
),
)
assertEquals(expectedErrorState, viewModel.stateFlow.value)
verify(exactly = 0) {
authRepository.clearBiometrics(userId = any())
bitwardenCredentialManager.isUserVerified = true
}
viewModel.trySendAction(VaultUnlockAction.DismissDialog)
assertEquals(expectedErrorState.copy(dialog = null), viewModel.stateFlow.value)
val retryCipher = mockk<Cipher>()
every { authRepository.getOrCreateCipher(USER_ID) } returns retryCipher
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick)
assertEquals(VaultUnlockEvent.PromptForBiometrics(retryCipher), awaitItem())
coEvery {
vaultRepository.unlockVaultWithBiometrics(cipher = retryCipher)
} returns VaultUnlockResult.Success
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(retryCipher))
assertEquals(
expectedErrorState.copy(dialog = null),
viewModel.stateFlow.value,
)
verify(exactly = 0) {
authRepository.clearBiometrics(userId = any())
bitwardenCredentialManager.isUserVerified = true
}
coVerify(exactly = 1) {
vaultRepository.unlockVaultWithBiometrics(cipher = CIPHER)
vaultRepository.unlockVaultWithBiometrics(cipher = retryCipher)
}
verify { authRepository.getOrCreateCipher(USER_ID) }
}
}
@Suppress("MaxLineLength")
@Test
fun `on BiometricsUnlockSuccess should preserve biometrics on the first Keystore authorization error and clear them on the second`() =
runTest {
val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)),
)
val viewModel = createViewModel(state = initialState)
val error = Throwable("Fail")
coEvery {
vaultRepository.unlockVaultWithBiometrics(cipher = CIPHER)
} returns VaultUnlockResult.BiometricKeystoreAuthorizationError(error = error)
every { authRepository.clearBiometrics(userId = USER_ID) } just runs
viewModel.eventFlow.test {
assertEquals(VaultUnlockEvent.PromptForBiometrics(CIPHER), awaitItem())
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
val firstFailureState = initialState.copy(
keystoreAuthorizationFailureCount = 1,
dialog = VaultUnlockState.VaultUnlockDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
throwable = error,
),
)
assertEquals(firstFailureState, viewModel.stateFlow.value)
verify(exactly = 0) {
authRepository.clearBiometrics(userId = any())
bitwardenCredentialManager.isUserVerified = true
}
viewModel.trySendAction(VaultUnlockAction.DismissDialog)
assertEquals(firstFailureState.copy(dialog = null), viewModel.stateFlow.value)
val retryCipher = mockk<Cipher>()
every { authRepository.getOrCreateCipher(USER_ID) } returns retryCipher
coEvery {
vaultRepository.unlockVaultWithBiometrics(cipher = retryCipher)
} returns VaultUnlockResult.BiometricKeystoreAuthorizationError(error = error)
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick)
assertEquals(VaultUnlockEvent.PromptForBiometrics(retryCipher), awaitItem())
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(retryCipher))
assertEquals(
initialState.copy(
isBiometricsValid = false,
keystoreAuthorizationFailureCount = 2,
dialog = VaultUnlockState.VaultUnlockDialog.Error(
title = BitwardenString.biometrics_failed.asText(),
message = BitwardenString.biometrics_decoding_failure.asText(),
throwable = error,
),
),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
authRepository.clearBiometrics(userId = USER_ID)
}
verify(exactly = 0) {
bitwardenCredentialManager.isUserVerified = true
}
}
}
@Suppress("MaxLineLength")
@Test
fun `on BiometricsUnlockSuccess should clear biometrics on a Keystore authorization error when the state already holds a prior failure`() {
val initialState = DEFAULT_STATE.copy(
isBiometricEnabled = true,
keystoreAuthorizationFailureCount = 1,
)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)),
)
val viewModel = createViewModel(state = initialState)
val error = Throwable("Fail")
coEvery {
vaultRepository.unlockVaultWithBiometrics(cipher = CIPHER)
} returns VaultUnlockResult.BiometricKeystoreAuthorizationError(error = error)
every { authRepository.clearBiometrics(userId = USER_ID) } just runs
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
assertEquals(
initialState.copy(
isBiometricsValid = false,
keystoreAuthorizationFailureCount = 2,
dialog = VaultUnlockState.VaultUnlockDialog.Error(
title = BitwardenString.biometrics_failed.asText(),
message = BitwardenString.biometrics_decoding_failure.asText(),
throwable = error,
),
),
viewModel.stateFlow.value,
)
verify(exactly = 1) { authRepository.clearBiometrics(userId = USER_ID) }
}
@Suppress("MaxLineLength")
@Test
fun `on BiometricsUnlockSuccess should not clear biometrics when a Keystore authorization error follows a successful unlock`() =
runTest {
val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)),
)
val viewModel = createViewModel(state = initialState)
val error = Throwable("Fail")
coEvery {
vaultRepository.unlockVaultWithBiometrics(cipher = CIPHER)
} returns VaultUnlockResult.BiometricKeystoreAuthorizationError(error = error)
every { authRepository.clearBiometrics(userId = any()) } just runs
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
verify(exactly = 0) { authRepository.clearBiometrics(userId = any()) }
assertEquals(1, viewModel.stateFlow.value.keystoreAuthorizationFailureCount)
coEvery {
vaultRepository.unlockVaultWithBiometrics(cipher = CIPHER)
} returns VaultUnlockResult.Success
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value)
coEvery {
vaultRepository.unlockVaultWithBiometrics(cipher = CIPHER)
} returns VaultUnlockResult.BiometricKeystoreAuthorizationError(error = error)
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
assertEquals(
initialState.copy(
keystoreAuthorizationFailureCount = 1,
dialog = VaultUnlockState.VaultUnlockDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
throwable = error,
),
),
viewModel.stateFlow.value,
)
verify(exactly = 0) { authRepository.clearBiometrics(userId = any()) }
}
@Test
fun `keystore authorization failure count should reset when the active user changes`() {
val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)),
@@ -1120,23 +1328,29 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
val error = Throwable("Fail")
coEvery {
vaultRepository.unlockVaultWithBiometrics(cipher = CIPHER)
} returns VaultUnlockResult.AuthenticationError(error = error)
} returns VaultUnlockResult.BiometricKeystoreAuthorizationError(error = error)
every { authRepository.clearBiometrics(userId = any()) } just runs
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
verify(exactly = 0) { authRepository.clearBiometrics(userId = any()) }
assertEquals(1, viewModel.stateFlow.value.keystoreAuthorizationFailureCount)
val updatedUserId = "updatedUserId"
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
activeUserId = updatedUserId,
accounts = listOf(
DEFAULT_ACCOUNT.copy(
userId = updatedUserId,
isVaultUnlocked = false,
isBiometricsEnabled = true,
),
),
)
assertEquals(0, viewModel.stateFlow.value.keystoreAuthorizationFailureCount)
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
assertEquals(
initialState.copy(
dialog = VaultUnlockState.VaultUnlockDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
throwable = error,
),
),
viewModel.stateFlow.value,
)
coVerify(exactly = 1) {
vaultRepository.unlockVaultWithBiometrics(cipher = CIPHER)
}
verify(exactly = 0) { authRepository.clearBiometrics(userId = any()) }
}
@Suppress("MaxLineLength")