From f3f35511a42bc4743febb2aeae6dfeb1ec5705af Mon Sep 17 00:00:00 2001 From: Caleb Derosier <125901828+caleb-livefront@users.noreply.github.com> Date: Wed, 8 May 2024 11:19:54 -0600 Subject: [PATCH] BIT-2170: Fix biometric bypass (#1324) --- .../manager/BiometricsEncryptionManager.kt | 23 ++++- .../BiometricsEncryptionManagerImpl.kt | 99 ++++++++++++++++--- .../feature/vaultunlock/VaultUnlockScreen.kt | 44 +++------ .../vaultunlock/VaultUnlockViewModel.kt | 39 +++++++- .../accountsecurity/AccountSecurityScreen.kt | 56 ++++++++--- .../AccountSecurityViewModel.kt | 46 ++++++++- .../manager/biometrics/BiometricsManager.kt | 6 +- .../biometrics/BiometricsManagerImpl.kt | 8 +- .../vaultunlock/VaultUnlockScreenTest.kt | 48 +++++---- .../vaultunlock/VaultUnlockViewModelTest.kt | 96 +++++++++++++++--- .../AccountSecurityScreenTest.kt | 59 +++++++---- .../AccountSecurityViewModelTest.kt | 62 +++++++++--- 12 files changed, 449 insertions(+), 137 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt index 38532cc89..c70fc33c5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManager.kt @@ -1,9 +1,25 @@ package com.x8bit.bitwarden.data.platform.manager +import javax.crypto.Cipher + /** * Responsible for managing Android keystore encryption and decryption. */ interface BiometricsEncryptionManager { + /** + * Creates a [Cipher] built from a keystore. + */ + fun createCipher( + userId: String, + ): Cipher + + /** + * Gets the [Cipher] built from a keystore, or creates one if it doesn't already exist. + */ + fun getOrCreateCipher( + userId: String, + ): Cipher? + /** * Sets up biometrics to ensure future integrity checks work properly. If this method has never * been called [isBiometricIntegrityValid] will return false. @@ -12,8 +28,11 @@ interface BiometricsEncryptionManager { /** * Checks to verify that the biometrics integrity is still valid. This returns `true` if the - * biometrics data has not change since the app setup biometrics, `false` will be returned if + * biometrics data has not changed since the app setup biometrics; `false` will be returned if * it has changed. */ - fun isBiometricIntegrityValid(userId: String): Boolean + fun isBiometricIntegrityValid( + userId: String, + cipher: Cipher?, + ): Boolean } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt index 6bce970af..481cb7802 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/BiometricsEncryptionManagerImpl.kt @@ -6,12 +6,14 @@ import android.security.keystore.KeyProperties import com.x8bit.bitwarden.BuildConfig import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import java.security.InvalidAlgorithmParameterException import java.security.InvalidKeyException import java.security.KeyStore import java.security.UnrecoverableKeyException import java.util.UUID import javax.crypto.Cipher import javax.crypto.KeyGenerator +import javax.crypto.SecretKey /** * Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption @@ -37,12 +39,41 @@ class BiometricsEncryptionManagerImpl( .setInvalidatedByBiometricEnrollment(true) .build() + override fun createCipher(userId: String): Cipher { + val secretKey: SecretKey = generateKey() + val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) + // This should never fail to initialize / return false because the cipher is newly generated + initializeCipher( + userId = userId, + cipher = cipher, + secretKey = secretKey, + ) + return cipher + } + + override fun getOrCreateCipher(userId: String): Cipher? { + val secretKey = try { + getSecretKey() ?: generateKey() + } catch (e: InvalidAlgorithmParameterException) { + // user removed all biometrics from the device + settingsDiskSource.systemBiometricIntegritySource = null + return null + } + val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) + val isCipherInitialized = initializeCipher( + userId = userId, + cipher = cipher, + secretKey = secretKey, + ) + return cipher?.takeIf { isCipherInitialized } + } + override fun setupBiometrics(userId: String) { createIntegrityValues(userId) } - override fun isBiometricIntegrityValid(userId: String): Boolean = - isSystemBiometricIntegrityValid(userId) && isAccountBiometricIntegrityValid(userId) + override fun isBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean = + isSystemBiometricIntegrityValid(userId, cipher) && isAccountBiometricIntegrityValid(userId) private fun isAccountBiometricIntegrityValid(userId: String): Boolean { val systemBioIntegrityState = settingsDiskSource @@ -56,12 +87,37 @@ class BiometricsEncryptionManagerImpl( ?: false } - private fun isSystemBiometricIntegrityValid(userId: String): Boolean = + /** + * Generates a [SecretKey] from which the [Cipher] will be generated. + */ + private fun generateKey(): SecretKey { + val keyGen = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ENCRYPTION_KEYSTORE_NAME, + ) + keyGen.init(keyGenParameterSpec) + keyGen.generateKey() + return requireNotNull(getSecretKey()) + } + + /** + * Returns the [SecretKey] stored in the keystore, or null if there isn't one. + */ + private fun getSecretKey(): SecretKey? { + keystore.load(null) + return keystore.getKey(ENCRYPTION_KEY_NAME, null) as? SecretKey + } + + /** + * Initialize a [Cipher] and return a boolean indicating whether it is valid. + */ + private fun initializeCipher( + userId: String, + cipher: Cipher, + secretKey: SecretKey, + ): Boolean = try { - keystore.load(null) - keystore - .getKey(ENCRYPTION_KEY_NAME, null) - ?.let { Cipher.getInstance(CIPHER_TRANSFORMATION).init(Cipher.ENCRYPT_MODE, it) } + cipher.init(Cipher.ENCRYPT_MODE, secretKey) true } catch (e: KeyPermanentlyInvalidatedException) { // Biometric has changed @@ -72,11 +128,31 @@ class BiometricsEncryptionManagerImpl( settingsDiskSource.systemBiometricIntegritySource = null false } catch (e: InvalidKeyException) { - // Fallback for old bitwarden users without a key + // Fallback for old Bitwarden users without a key createIntegrityValues(userId) true } + /** + * Validates the keystore key and decrypts it using the user-provided [cipher]. + */ + private fun isSystemBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean { + val secretKey = getSecretKey() + return if (cipher != null && secretKey != null) { + initializeCipher( + userId = userId, + cipher = cipher, + secretKey = secretKey, + ) + } else { + false + } + } + + /** + * Creates the initial values to be used for biometrics, including the key from which the + * master [Cipher] will be generated. + */ @Suppress("TooGenericExceptionCaught") private fun createIntegrityValues(userId: String) { val systemBiometricIntegritySource = settingsDiskSource @@ -90,12 +166,7 @@ class BiometricsEncryptionManagerImpl( ) try { - val keyGen = KeyGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_AES, - ENCRYPTION_KEYSTORE_NAME, - ) - keyGen.init(keyGenParameterSpec) - keyGen.generateKey() + createCipher(userId) } catch (e: Exception) { // Catch silently to allow biometrics to function on devices that are in // a state where key generation is not functioning diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt index 03e7ffc0a..c47785e46 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt @@ -57,6 +57,7 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import javax.crypto.Cipher /** * The top level composable for the Vault Unlock screen. @@ -72,8 +73,8 @@ fun VaultUnlockScreen( val context = LocalContext.current val resources = context.resources - val onBiometricsUnlockClick: () -> Unit = remember(viewModel) { - { viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) } + val onBiometricsUnlockSuccess: (cipher: Cipher?) -> Unit = remember(viewModel) { + { viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(it)) } } val onBiometricsLockOut: () -> Unit = remember(viewModel) { { viewModel.trySendAction(VaultUnlockAction.BiometricsLockOut) } @@ -85,10 +86,17 @@ fun VaultUnlockScreen( Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() } - VaultUnlockEvent.PromptForBiometrics -> { - biometricsManager.promptForBiometrics( - onSuccess = onBiometricsUnlockClick, + is VaultUnlockEvent.PromptForBiometrics -> { + biometricsManager.promptBiometrics( + onSuccess = onBiometricsUnlockSuccess, + onCancel = { + // no-op + }, + onError = { + // no-op + }, onLockOut = onBiometricsLockOut, + cipher = event.cipher, ) } } @@ -216,11 +224,8 @@ fun VaultUnlockScreen( if (state.showBiometricLogin && biometricsManager.isBiometricsSupported) { BitwardenOutlinedButton( label = stringResource(id = R.string.use_biometrics_to_unlock), - onClick = { - biometricsManager.promptForBiometrics( - onSuccess = onBiometricsUnlockClick, - onLockOut = onBiometricsLockOut, - ) + onClick = remember(viewModel) { + { viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) } }, modifier = Modifier .padding(horizontal = 16.dp) @@ -268,22 +273,3 @@ fun VaultUnlockScreen( } } } - -/** - * Helper method for easier prompting for biometrics. - */ -private fun BiometricsManager.promptForBiometrics( - onSuccess: () -> Unit, - onLockOut: () -> Unit, -) { - promptBiometrics( - onSuccess = onSuccess, - onCancel = { - // no-op - }, - onError = { - // no-op - }, - onLockOut = onLockOut, - ) -} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt index 12678b1df..1a32de59f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import javax.crypto.Cipher import javax.inject.Inject private const val KEY_STATE = "state" @@ -51,6 +52,7 @@ class VaultUnlockViewModel @Inject constructor( val activeAccountSummary = userState.toActiveAccountSummary() val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid( userId = userState.activeUserId, + cipher = biometricsEncryptionManager.getOrCreateCipher(userState.activeUserId), ) val vaultUnlockType = userState.activeAccount.vaultUnlockType val hasNoMasterPassword = trustedDevice?.hasMasterPassword == false @@ -73,6 +75,7 @@ class VaultUnlockViewModel @Inject constructor( isBiometricsValid = isBiometricsValid, showAccountMenu = VaultUnlockArgs(savedStateHandle).unlockType == UnlockType.STANDARD, vaultUnlockType = vaultUnlockType, + userId = userState.activeUserId, ) }, ) { @@ -95,8 +98,13 @@ class VaultUnlockViewModel @Inject constructor( } .launchIn(viewModelScope) - if (state.showBiometricLogin) { - sendEvent(VaultUnlockEvent.PromptForBiometrics) + val cipher = biometricsEncryptionManager.getOrCreateCipher(state.userId) + if (state.showBiometricLogin && cipher != null) { + sendEvent( + VaultUnlockEvent.PromptForBiometrics( + cipher = cipher, + ), + ) } } @@ -111,6 +119,7 @@ class VaultUnlockViewModel @Inject constructor( is VaultUnlockAction.SwitchAccountClick -> handleSwitchAccountClick(action) VaultUnlockAction.BiometricsLockOut -> handleBiometricsLockOut() VaultUnlockAction.BiometricsUnlockClick -> handleBiometricsUnlockClick() + is VaultUnlockAction.BiometricsUnlockSuccess -> handleBiometricsUnlockSuccess(action) VaultUnlockAction.UnlockClick -> handleUnlockClick() is VaultUnlockAction.Internal -> handleInternalAction(action) } @@ -151,8 +160,22 @@ class VaultUnlockViewModel @Inject constructor( } private fun handleBiometricsUnlockClick() { + val cipher = biometricsEncryptionManager.getOrCreateCipher(state.userId) + if (cipher != null) { + sendEvent( + event = VaultUnlockEvent.PromptForBiometrics( + cipher = cipher, + ), + ) + } else { + mutableStateFlow.update { it.copy(isBiometricsValid = false) } + // TODO BIT-2345 show failure message when user added a new fingerprint + } + } + + private fun handleBiometricsUnlockSuccess(action: VaultUnlockAction.BiometricsUnlockSuccess) { val activeUserId = authRepository.activeUserId ?: return - if (!biometricsEncryptionManager.isBiometricIntegrityValid(activeUserId)) { + if (!biometricsEncryptionManager.isBiometricIntegrityValid(activeUserId, action.cipher)) { mutableStateFlow.update { it.copy(isBiometricsValid = false) } return } @@ -298,6 +321,7 @@ data class VaultUnlockState( val isBiometricEnabled: Boolean, val showAccountMenu: Boolean, val vaultUnlockType: VaultUnlockType, + val userId: String, ) : Parcelable { /** @@ -344,7 +368,7 @@ sealed class VaultUnlockEvent { /** * Prompts the user for biometrics unlock. */ - data object PromptForBiometrics : VaultUnlockEvent() + data class PromptForBiometrics(val cipher: Cipher) : VaultUnlockEvent() } /** @@ -401,6 +425,13 @@ sealed class VaultUnlockAction { */ data object BiometricsUnlockClick : VaultUnlockAction() + /** + * The user has received a successful response from the biometrics call. + */ + data class BiometricsUnlockSuccess( + val cipher: Cipher?, + ) : VaultUnlockAction() + /** * The user has attempted to login with biometrics too many times and has been locked out. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index 96de7d8d1..a9febdc8c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -84,6 +84,16 @@ fun AccountSecurityScreen( val state by viewModel.stateFlow.collectAsState() val context = LocalContext.current val resources = context.resources + var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) } + val unlockWithBiometricToggle: () -> Unit = remember(viewModel) { + { + viewModel.trySendAction( + action = AccountSecurityAction.UnlockWithBiometricToggle( + enabled = true, + ), + ) + } + } EventsEffect(viewModel = viewModel) { event -> when (event) { AccountSecurityEvent.NavigateBack -> onNavigateBack() @@ -108,6 +118,20 @@ fun AccountSecurityScreen( intentManager.launchUri(event.url.toUri()) } + is AccountSecurityEvent.ShowBiometricsPrompt -> { + showBiometricsPrompt = true + biometricsManager.promptBiometrics( + onSuccess = { + unlockWithBiometricToggle() + showBiometricsPrompt = false + }, + onCancel = { showBiometricsPrompt = false }, + onLockOut = { showBiometricsPrompt = false }, + onError = { showBiometricsPrompt = false }, + cipher = event.cipher, + ) + } + is AccountSecurityEvent.ShowToast -> { Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() } @@ -174,8 +198,18 @@ fun AccountSecurityScreen( ) UnlockWithBiometricsRow( isChecked = state.isUnlockWithBiometricsEnabled, - onBiometricToggle = remember(viewModel) { - { viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(it)) } + showBiometricsPrompt = showBiometricsPrompt, + onDisableBiometrics = remember(viewModel) { + { + viewModel.trySendAction( + AccountSecurityAction.UnlockWithBiometricToggle( + enabled = false, + ), + ) + } + }, + onEnableBiometrics = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.EnableBiometricsClick) } }, biometricsManager = biometricsManager, modifier = Modifier @@ -349,12 +383,13 @@ private fun AccountSecurityDialogs( @Composable private fun UnlockWithBiometricsRow( isChecked: Boolean, - onBiometricToggle: (Boolean) -> Unit, + showBiometricsPrompt: Boolean, + onDisableBiometrics: () -> Unit, + onEnableBiometrics: () -> Unit, biometricsManager: BiometricsManager, modifier: Modifier = Modifier, ) { if (!biometricsManager.isBiometricsSupported) return - var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) } BitwardenWideSwitch( modifier = modifier, label = stringResource( @@ -364,18 +399,9 @@ private fun UnlockWithBiometricsRow( isChecked = isChecked || showBiometricsPrompt, onCheckedChange = { toggled -> if (toggled) { - showBiometricsPrompt = true - biometricsManager.promptBiometrics( - onSuccess = { - onBiometricToggle(true) - showBiometricsPrompt = false - }, - onCancel = { showBiometricsPrompt = false }, - onLockOut = { showBiometricsPrompt = false }, - onError = { showBiometricsPrompt = false }, - ) + onEnableBiometrics() } else { - onBiometricToggle(false) + onDisableBiometrics() } }, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index bd81da627..1e4e97a4a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.auth.repository.util.policyInformation +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -27,6 +28,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import javax.crypto.Cipher import javax.inject.Inject private const val KEY_STATE = "state" @@ -34,21 +36,28 @@ private const val KEY_STATE = "state" /** * View model for the account security screen. */ -@Suppress("TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class AccountSecurityViewModel @Inject constructor( private val authRepository: AuthRepository, private val vaultRepository: VaultRepository, private val settingsRepository: SettingsRepository, private val environmentRepository: EnvironmentRepository, + private val biometricsEncryptionManager: BiometricsEncryptionManager, policyManager: PolicyManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel( - initialState = savedStateHandle[KEY_STATE] - ?: AccountSecurityState( + initialState = savedStateHandle[KEY_STATE] ?: run { + val userId = requireNotNull(authRepository.userStateFlow.value).activeUserId + val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid( + userId = userId, + cipher = biometricsEncryptionManager.getOrCreateCipher(userId), + ) + AccountSecurityState( dialog = null, fingerprintPhrase = "".asText(), // This will be filled in dynamically - isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, + isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled && + isBiometricsValid, isUnlockWithPasswordEnabled = authRepository .userStateFlow .value @@ -56,11 +65,13 @@ class AccountSecurityViewModel @Inject constructor( ?.trustedDevice ?.hasMasterPassword != false, isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled, + userId = userId, vaultTimeout = settingsRepository.vaultTimeout, vaultTimeoutAction = settingsRepository.vaultTimeoutAction, vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyAction = null, - ), + ) + }, ) { private val webSettingsUrl: String get() { @@ -104,6 +115,7 @@ class AccountSecurityViewModel @Inject constructor( AccountSecurityAction.ConfirmLogoutClick -> handleConfirmLogoutClick() AccountSecurityAction.DeleteAccountClick -> handleDeleteAccountClick() AccountSecurityAction.DismissDialog -> handleDismissDialog() + AccountSecurityAction.EnableBiometricsClick -> handleEnableBiometricsClick() AccountSecurityAction.FingerPrintLearnMoreClick -> handleFingerPrintLearnMoreClick() AccountSecurityAction.LockNowClick -> handleLockNowClick() AccountSecurityAction.LogoutClick -> handleLogoutClick() @@ -144,6 +156,17 @@ class AccountSecurityViewModel @Inject constructor( mutableStateFlow.update { it.copy(dialog = null) } } + private fun handleEnableBiometricsClick() { + sendEvent( + AccountSecurityEvent.ShowBiometricsPrompt( + // Generate a new key in case the previous one was invalidated + cipher = biometricsEncryptionManager.createCipher( + userId = state.userId, + ), + ), + ) + } + private fun handleFingerPrintLearnMoreClick() { sendEvent(AccountSecurityEvent.NavigateToFingerprintPhrase) } @@ -345,6 +368,7 @@ data class AccountSecurityState( val isUnlockWithBiometricsEnabled: Boolean, val isUnlockWithPasswordEnabled: Boolean, val isUnlockWithPinEnabled: Boolean, + val userId: String, val vaultTimeout: VaultTimeout, val vaultTimeoutAction: VaultTimeoutAction, val vaultTimeoutPolicyMinutes: Int?, @@ -423,6 +447,13 @@ sealed class AccountSecurityEvent { */ data class NavigateToChangeMasterPassword(val url: String) : AccountSecurityEvent() + /** + * Shows the prompt for biometrics using with the given [cipher]. + */ + data class ShowBiometricsPrompt( + val cipher: Cipher, + ) : AccountSecurityEvent() + /** * Displays a toast with the given [Text]. */ @@ -466,6 +497,11 @@ sealed class AccountSecurityAction { */ data object DismissDialog : AccountSecurityAction() + /** + * The user clicked to enable biometrics. + */ + data object EnableBiometricsClick : AccountSecurityAction() + /** * User clicked fingerprint phrase. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt index 68019c74e..1efe6fae0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManager.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.platform.manager.biometrics import androidx.compose.runtime.Immutable +import javax.crypto.Cipher /** * Interface to manage biometrics within the app. @@ -13,12 +14,13 @@ interface BiometricsManager { val isBiometricsSupported: Boolean /** - * Display a prompt for biometrics. + * Display a prompt for setting up or verifying biometrics. */ fun promptBiometrics( - onSuccess: () -> Unit, + onSuccess: (cipher: Cipher?) -> Unit, onCancel: () -> Unit, onLockOut: () -> Unit, onError: () -> Unit, + cipher: Cipher, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt index 31857b614..5f068ac6e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/biometrics/BiometricsManagerImpl.kt @@ -8,6 +8,7 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import javax.crypto.Cipher /** * Default implementation of the [BiometricsManager] to manage biometrics within the app. @@ -35,10 +36,11 @@ class BiometricsManagerImpl( } override fun promptBiometrics( - onSuccess: () -> Unit, + onSuccess: (cipher: Cipher?) -> Unit, onCancel: () -> Unit, onLockOut: () -> Unit, onError: () -> Unit, + cipher: Cipher, ) { val biometricPrompt = BiometricPrompt( fragmentActivity, @@ -46,7 +48,7 @@ class BiometricsManagerImpl( object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded( result: BiometricPrompt.AuthenticationResult, - ) = onSuccess() + ) = onSuccess(result.cryptoObject?.cipher) override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { when (errorCode) { @@ -84,6 +86,6 @@ class BiometricsManagerImpl( .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG) .build() - biometricPrompt.authenticate(promptInfo) + biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt index f65aa9dc7..c49c3617b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.junit.Before import org.junit.Test +import javax.crypto.Cipher class VaultUnlockScreenTest : BaseComposeTest() { @@ -51,7 +52,7 @@ class VaultUnlockScreenTest : BaseComposeTest() { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow } - private val captureBiometricsSuccess = slot<() -> Unit>() + private val captureBiometricsSuccess = slot<(cipher: Cipher?) -> Unit>() private val captureBiometricsLockOut = slot<() -> Unit>() private val biometricsManager: BiometricsManager = mockk { every { isBiometricsSupported } returns true @@ -61,6 +62,7 @@ class VaultUnlockScreenTest : BaseComposeTest() { onCancel = any(), onLockOut = capture(captureBiometricsLockOut), onError = any(), + cipher = CIPHER, ) } just runs } @@ -76,18 +78,37 @@ class VaultUnlockScreenTest : BaseComposeTest() { } @Test - fun `on PromptForBiometrics should call launchUri on intentManager`() { - mutableEventFlow.tryEmit(VaultUnlockEvent.PromptForBiometrics) - verify { + fun `on PromptForBiometrics should call promptBiometrics on biometricsManager`() { + mutableEventFlow.tryEmit(VaultUnlockEvent.PromptForBiometrics(CIPHER)) + verify(exactly = 1) { biometricsManager.promptBiometrics( onSuccess = any(), onCancel = any(), onError = any(), onLockOut = any(), + cipher = any(), ) } } + @Test + fun `on biometrics authentication success should send BiometricsUnlockSuccess`() { + mutableEventFlow.tryEmit(VaultUnlockEvent.PromptForBiometrics(CIPHER)) + captureBiometricsSuccess.captured(CIPHER) + verify(exactly = 1) { + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER)) + } + } + + @Test + fun `on biometrics authentication lockout should send BiometricsLockOut`() { + mutableEventFlow.tryEmit(VaultUnlockEvent.PromptForBiometrics(CIPHER)) + captureBiometricsLockOut.captured() + verify(exactly = 1) { + viewModel.trySendAction(VaultUnlockAction.BiometricsLockOut) + } + } + @Test fun `account icon click should show the account switcher`() { composeTestRule.assertSwitcherIsNotDisplayed( @@ -408,32 +429,17 @@ class VaultUnlockScreenTest : BaseComposeTest() { } } - @Suppress("MaxLineLength") @Test - fun `unlock with biometrics click should send BiometricsUnlockClick on biometrics authentication success`() { + fun `unlock with biometrics click should send BiometricsUnlockClick`() { composeTestRule .onNodeWithText("Use biometrics to unlock") .performScrollTo() .performClick() - captureBiometricsSuccess.captured() verify(exactly = 1) { viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) } } - @Suppress("MaxLineLength") - @Test - fun `unlock with biometrics click should send BiometricsLockOut on biometrics authentication lock out`() { - composeTestRule - .onNodeWithText("Use biometrics to unlock") - .performScrollTo() - .performClick() - captureBiometricsLockOut.captured() - verify(exactly = 1) { - viewModel.trySendAction(VaultUnlockAction.BiometricsLockOut) - } - } - @Test fun `account button should update according to state`() { mutableStateFlow.update { it.copy(showAccountMenu = true) } @@ -490,6 +496,7 @@ private val ACCOUNT_SUMMARIES = listOf( LOCKED_ACCOUNT_SUMMARY, ) +private val CIPHER = mockk() private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( accountSummaries = ACCOUNT_SUMMARIES, avatarColorString = "0000FF", @@ -502,5 +509,6 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( isBiometricsValid = true, isBiometricEnabled = true, showAccountMenu = true, + userId = ACTIVE_ACCOUNT_SUMMARY.userId, vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt index 133678494..e99e0530b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import javax.crypto.Cipher @Suppress("LargeClass") class VaultUnlockViewModelTest : BaseViewModelTest() { @@ -52,7 +53,19 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { every { lockVault(any()) } just runs } private val encryptionManager: BiometricsEncryptionManager = mockk { - every { isBiometricIntegrityValid(userId = DEFAULT_USER_STATE.activeUserId) } returns true + every { getOrCreateCipher(USER_ID) } returns CIPHER + every { + isBiometricIntegrityValid( + userId = DEFAULT_USER_STATE.activeUserId, + cipher = CIPHER, + ) + } returns true + every { + isBiometricIntegrityValid( + userId = DEFAULT_USER_STATE.activeUserId, + cipher = null, + ) + } returns false } @Test @@ -64,7 +77,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { val viewModel = createViewModel(state = initialState) viewModel.eventFlow.test { - assertEquals(VaultUnlockEvent.PromptForBiometrics, awaitItem()) + assertEquals(VaultUnlockEvent.PromptForBiometrics(CIPHER), awaitItem()) } } @@ -72,6 +85,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { fun `initial state should be correct when not set`() { val viewModel = createViewModel() assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + verify { encryptionManager.getOrCreateCipher(USER_ID) } } @Test @@ -283,6 +297,32 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { ) } + @Test + fun `on BiometricsUnlockClick should emit PromptForBiometrics when cipher is non-null`() = + runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + assertEquals(VaultUnlockEvent.PromptForBiometrics(CIPHER), awaitItem()) + } + verify { encryptionManager.getOrCreateCipher(USER_ID) } + } + + @Test + fun `on BiometricsUnlockClick should disable isBiometricsValid when cipher is null`() { + val initialState = DEFAULT_STATE.copy(isBiometricsValid = true) + val viewModel = createViewModel(state = initialState) + every { encryptionManager.getOrCreateCipher(USER_ID) } returns null + + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + assertEquals( + initialState.copy(isBiometricsValid = false), + viewModel.stateFlow.value, + ) + verify { encryptionManager.getOrCreateCipher(USER_ID) } + } + @Test fun `on AddAccountClick should set hasPendingAccountAddition to true on the AuthRepository`() { val viewModel = createViewModel() @@ -649,7 +689,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `on BiometricsUnlockClick should display error dialog on unlockVaultWithBiometrics AuthenticationError`() { + fun `on BiometricsUnlockSuccess should display error dialog on unlockVaultWithBiometrics AuthenticationError`() { val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true) mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), @@ -659,7 +699,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { vaultRepository.unlockVaultWithBiometrics() } returns VaultUnlockResult.AuthenticationError - viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER)) assertEquals( initialState.copy( @@ -676,7 +716,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `on BiometricsUnlockClick should display error dialog on unlockVaultWithBiometrics GenericError`() { + fun `on BiometricsUnlockSuccess should display error dialog on unlockVaultWithBiometrics GenericError`() { val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true) mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), @@ -686,7 +726,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { vaultRepository.unlockVaultWithBiometrics() } returns VaultUnlockResult.GenericError - viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER)) assertEquals( initialState.copy( @@ -703,7 +743,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `on BiometricsUnlockClick should display error dialog on unlockVaultWithBiometrics InvalidStateError`() { + fun `on BiometricsUnlockSuccess should display error dialog on unlockVaultWithBiometrics InvalidStateError`() { val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true) mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), @@ -713,7 +753,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { vaultRepository.unlockVaultWithBiometrics() } returns VaultUnlockResult.InvalidStateError - viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER)) assertEquals( initialState.copy( @@ -729,7 +769,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { } @Test - fun `on BiometricsUnlockClick should clear dialog on unlockVaultWithBiometrics success`() { + fun `on BiometricsUnlockSuccess should clear dialog on unlockVaultWithBiometrics success`() { val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true) mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), @@ -739,7 +779,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { vaultRepository.unlockVaultWithBiometrics() } returns VaultUnlockResult.Success - viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER)) assertEquals( initialState.copy(dialog = null), @@ -751,7 +791,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { } @Test - fun `on BiometricsUnlockClick should clear dialog when user has changed`() { + fun `on BiometricsUnlockSuccess should clear dialog when user has changed`() { val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true) mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), @@ -762,7 +802,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { vaultRepository.unlockVaultWithBiometrics() } coAnswers { resultFlow.first() } - viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER)) assertEquals( initialState.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading), @@ -782,6 +822,31 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { } } + @Test + fun `on BiometricsUnlockSuccess should set isBiometricsValid to false with null cipher`() { + val initialState = DEFAULT_STATE.copy( + isBiometricEnabled = true, + isBiometricsValid = true, + ) + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), + ) + val viewModel = createViewModel(state = initialState) + coEvery { + vaultRepository.unlockVaultWithBiometrics() + } returns VaultUnlockResult.Success + + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(cipher = null)) + + assertEquals( + initialState.copy( + dialog = null, + isBiometricsValid = false, + ), + viewModel.stateFlow.value, + ) + } + private fun createViewModel( state: VaultUnlockState? = null, unlockType: UnlockType = UnlockType.STANDARD, @@ -800,6 +865,8 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { ) } +private val CIPHER = mockk() +private const val USER_ID: String = "activeUserId" private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( accountSummaries = listOf( AccountSummary( @@ -823,6 +890,7 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( isBiometricsValid = true, isBiometricEnabled = false, showAccountMenu = true, + userId = USER_ID, vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, ) @@ -835,7 +903,7 @@ private val TRUSTED_DEVICE: UserState.TrustedDevice = UserState.TrustedDevice( ) private val DEFAULT_ACCOUNT = UserState.Account( - userId = "activeUserId", + userId = USER_ID, name = "Active User", email = "active@bitwarden.com", environment = Environment.Us, @@ -851,6 +919,6 @@ private val DEFAULT_ACCOUNT = UserState.Account( ) private val DEFAULT_USER_STATE = UserState( - activeUserId = "activeUserId", + activeUserId = USER_ID, accounts = listOf(DEFAULT_ACCOUNT), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt index 35048e253..949b92950 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.update import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import javax.crypto.Cipher @Suppress("LargeClass") class AccountSecurityScreenTest : BaseComposeTest() { @@ -50,7 +51,7 @@ class AccountSecurityScreenTest : BaseComposeTest() { every { startActivity(any()) } just runs every { startApplicationDetailsSettingsActivity() } just runs } - private val captureBiometricsSuccess = slot<() -> Unit>() + private val captureBiometricsSuccess = slot<(cipher: Cipher?) -> Unit>() private val captureBiometricsCancel = slot<() -> Unit>() private val captureBiometricsLockOut = slot<() -> Unit>() private val captureBiometricsError = slot<() -> Unit>() @@ -62,6 +63,7 @@ class AccountSecurityScreenTest : BaseComposeTest() { onCancel = capture(captureBiometricsCancel), onLockOut = capture(captureBiometricsLockOut), onError = capture(captureBiometricsError), + cipher = CIPHER, ) } just runs } @@ -108,8 +110,9 @@ class AccountSecurityScreenTest : BaseComposeTest() { verify { viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick) } } + @Suppress("MaxLineLength") @Test - fun `on unlock with biometrics toggle should send UnlockWithBiometricToggle on success`() { + fun `on unlock with biometrics toggle should send EnableBiometricsClick when isUnlockWithBiometricsEnabled is false`() { composeTestRule .onNodeWithText("Unlock with Biometrics") .performScrollTo() @@ -118,17 +121,24 @@ class AccountSecurityScreenTest : BaseComposeTest() { .onNodeWithText("Unlock with Biometrics") .performScrollTo() .performClick() + verify(exactly = 1) { + viewModel.trySendAction(AccountSecurityAction.EnableBiometricsClick) + } + } + + @Test + fun `on unlock with biometrics toggle should send UnlockWithBiometricToggle`() { + mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = true) } composeTestRule .onNodeWithText("Unlock with Biometrics") .performScrollTo() .assertIsOn() - captureBiometricsSuccess.captured() composeTestRule .onNodeWithText("Unlock with Biometrics") .performScrollTo() - .assertIsOff() + .performClick() verify(exactly = 1) { - viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(true)) + viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(false)) } } @@ -138,10 +148,7 @@ class AccountSecurityScreenTest : BaseComposeTest() { .onNodeWithText("Unlock with Biometrics") .performScrollTo() .assertIsOff() - composeTestRule - .onNodeWithText("Unlock with Biometrics") - .performScrollTo() - .performClick() + mutableEventFlow.tryEmit(AccountSecurityEvent.ShowBiometricsPrompt(CIPHER)) composeTestRule .onNodeWithText("Unlock with Biometrics") .performScrollTo() @@ -162,10 +169,7 @@ class AccountSecurityScreenTest : BaseComposeTest() { .onNodeWithText("Unlock with Biometrics") .performScrollTo() .assertIsOff() - composeTestRule - .onNodeWithText("Unlock with Biometrics") - .performScrollTo() - .performClick() + mutableEventFlow.tryEmit(AccountSecurityEvent.ShowBiometricsPrompt(CIPHER)) composeTestRule .onNodeWithText("Unlock with Biometrics") .performScrollTo() @@ -186,10 +190,7 @@ class AccountSecurityScreenTest : BaseComposeTest() { .onNodeWithText("Unlock with Biometrics") .performScrollTo() .assertIsOff() - composeTestRule - .onNodeWithText("Unlock with Biometrics") - .performScrollTo() - .performClick() + mutableEventFlow.tryEmit(AccountSecurityEvent.ShowBiometricsPrompt(CIPHER)) composeTestRule .onNodeWithText("Unlock with Biometrics") .performScrollTo() @@ -204,6 +205,27 @@ class AccountSecurityScreenTest : BaseComposeTest() { } } + @Test + fun `on unlock with biometrics toggle should send UnlockWithBiometricToggle on success`() { + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOff() + mutableEventFlow.tryEmit(AccountSecurityEvent.ShowBiometricsPrompt(CIPHER)) + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOn() + captureBiometricsSuccess.captured(CIPHER) + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .assertIsOff() + verify(exactly = 1) { + viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(true)) + } + } + @Test fun `on unlock with biometrics should be toggled on or off according to state`() { composeTestRule.onNodeWithText("Unlock with Biometrics").assertIsOff() @@ -1359,12 +1381,15 @@ class AccountSecurityScreenTest : BaseComposeTest() { } } +private val CIPHER = mockk() +private const val USER_ID: String = "activeUserId" private val DEFAULT_STATE = AccountSecurityState( dialog = null, fingerprintPhrase = "fingerprint-placeholder".asText(), isUnlockWithBiometricsEnabled = false, isUnlockWithPasswordEnabled = true, isUnlockWithPinEnabled = false, + userId = USER_ID, vaultTimeout = VaultTimeout.ThirtyMinutes, vaultTimeoutAction = VaultTimeoutAction.LOCK, vaultTimeoutPolicyMinutes = null, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index 239b4f33a..6f98b381d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -36,6 +37,7 @@ import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import javax.crypto.Cipher class AccountSecurityViewModelTest : BaseViewModelTest() { @@ -53,6 +55,9 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) } private val mutableActivePolicyFlow = bufferedMutableSharedFlow>() + private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk { + every { createCipher(DEFAULT_USER_STATE.activeUserId) } returns CIPHER + } private val policyManager: PolicyManager = mockk { every { getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT) @@ -69,11 +74,27 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { @Test fun `initial state should be correct when saved state is not set`() { every { settingsRepository.isUnlockWithPinEnabled } returns true + every { + biometricsEncryptionManager.getOrCreateCipher(DEFAULT_USER_STATE.activeUserId) + } returns CIPHER + every { + biometricsEncryptionManager.isBiometricIntegrityValid( + userId = DEFAULT_USER_STATE.activeUserId, + cipher = CIPHER, + ) + } returns true val viewModel = createViewModel(initialState = null) assertEquals( DEFAULT_STATE.copy(isUnlockWithPinEnabled = true), viewModel.stateFlow.value, ) + verify { + biometricsEncryptionManager.getOrCreateCipher(DEFAULT_USER_STATE.activeUserId) + biometricsEncryptionManager.isBiometricIntegrityValid( + userId = DEFAULT_USER_STATE.activeUserId, + cipher = CIPHER, + ) + } coVerify { settingsRepository.getUserFingerprint() } } @@ -306,6 +327,19 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { } } + @Test + fun `on EnableBiometricsClick should emit ShowBiometricsPrompt`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.EnableBiometricsClick) + + assertEquals( + AccountSecurityEvent.ShowBiometricsPrompt(CIPHER), + awaitItem(), + ) + } + } + @Test fun `on UnlockWithBiometricToggle false should call clearBiometricsKey and update the state`() = runTest { @@ -547,12 +581,14 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { vaultRepository: VaultRepository = this.vaultRepository, environmentRepository: EnvironmentRepository = this.fakeEnvironmentRepository, settingsRepository: SettingsRepository = this.settingsRepository, + biometricsEncryptionManager: BiometricsEncryptionManager = this.biometricsEncryptionManager, policyManager: PolicyManager = this.policyManager, ): AccountSecurityViewModel = AccountSecurityViewModel( authRepository = authRepository, vaultRepository = vaultRepository, settingsRepository = settingsRepository, environmentRepository = environmentRepository, + biometricsEncryptionManager = biometricsEncryptionManager, policyManager = policyManager, savedStateHandle = SavedStateHandle().apply { set("state", initialState) @@ -560,20 +596,9 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { ) } +private val CIPHER = mockk() private const val FINGERPRINT: String = "fingerprint" -private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState( - dialog = null, - fingerprintPhrase = FINGERPRINT.asText(), - isUnlockWithBiometricsEnabled = false, - isUnlockWithPasswordEnabled = true, - isUnlockWithPinEnabled = false, - vaultTimeout = VaultTimeout.ThirtyMinutes, - vaultTimeoutAction = VaultTimeoutAction.LOCK, - vaultTimeoutPolicyMinutes = null, - vaultTimeoutPolicyAction = null, -) - private val DEFAULT_USER_STATE = UserState( activeUserId = "activeUserId", accounts = listOf( @@ -594,3 +619,16 @@ private val DEFAULT_USER_STATE = UserState( ), ), ) + +private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState( + dialog = null, + fingerprintPhrase = FINGERPRINT.asText(), + isUnlockWithBiometricsEnabled = false, + isUnlockWithPasswordEnabled = true, + isUnlockWithPinEnabled = false, + userId = DEFAULT_USER_STATE.activeUserId, + vaultTimeout = VaultTimeout.ThirtyMinutes, + vaultTimeoutAction = VaultTimeoutAction.LOCK, + vaultTimeoutPolicyMinutes = null, + vaultTimeoutPolicyAction = null, +)