BIT-2170: Fix biometric bypass (#1324)

This commit is contained in:
Caleb Derosier 2024-05-08 11:19:54 -06:00 committed by Álison Fernandes
parent 4880d0b89d
commit f3f35511a4
12 changed files with 449 additions and 137 deletions

View file

@ -1,9 +1,25 @@
package com.x8bit.bitwarden.data.platform.manager package com.x8bit.bitwarden.data.platform.manager
import javax.crypto.Cipher
/** /**
* Responsible for managing Android keystore encryption and decryption. * Responsible for managing Android keystore encryption and decryption.
*/ */
interface BiometricsEncryptionManager { 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 * Sets up biometrics to ensure future integrity checks work properly. If this method has never
* been called [isBiometricIntegrityValid] will return false. * 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 * 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. * it has changed.
*/ */
fun isBiometricIntegrityValid(userId: String): Boolean fun isBiometricIntegrityValid(
userId: String,
cipher: Cipher?,
): Boolean
} }

View file

@ -6,12 +6,14 @@ import android.security.keystore.KeyProperties
import com.x8bit.bitwarden.BuildConfig import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import java.security.InvalidAlgorithmParameterException
import java.security.InvalidKeyException import java.security.InvalidKeyException
import java.security.KeyStore import java.security.KeyStore
import java.security.UnrecoverableKeyException import java.security.UnrecoverableKeyException
import java.util.UUID import java.util.UUID
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.KeyGenerator import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
/** /**
* Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption * Default implementation of [BiometricsEncryptionManager] for managing Android keystore encryption
@ -37,12 +39,41 @@ class BiometricsEncryptionManagerImpl(
.setInvalidatedByBiometricEnrollment(true) .setInvalidatedByBiometricEnrollment(true)
.build() .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) { override fun setupBiometrics(userId: String) {
createIntegrityValues(userId) createIntegrityValues(userId)
} }
override fun isBiometricIntegrityValid(userId: String): Boolean = override fun isBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean =
isSystemBiometricIntegrityValid(userId) && isAccountBiometricIntegrityValid(userId) isSystemBiometricIntegrityValid(userId, cipher) && isAccountBiometricIntegrityValid(userId)
private fun isAccountBiometricIntegrityValid(userId: String): Boolean { private fun isAccountBiometricIntegrityValid(userId: String): Boolean {
val systemBioIntegrityState = settingsDiskSource val systemBioIntegrityState = settingsDiskSource
@ -56,12 +87,37 @@ class BiometricsEncryptionManagerImpl(
?: false ?: 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 { try {
keystore.load(null) cipher.init(Cipher.ENCRYPT_MODE, secretKey)
keystore
.getKey(ENCRYPTION_KEY_NAME, null)
?.let { Cipher.getInstance(CIPHER_TRANSFORMATION).init(Cipher.ENCRYPT_MODE, it) }
true true
} catch (e: KeyPermanentlyInvalidatedException) { } catch (e: KeyPermanentlyInvalidatedException) {
// Biometric has changed // Biometric has changed
@ -72,11 +128,31 @@ class BiometricsEncryptionManagerImpl(
settingsDiskSource.systemBiometricIntegritySource = null settingsDiskSource.systemBiometricIntegritySource = null
false false
} catch (e: InvalidKeyException) { } catch (e: InvalidKeyException) {
// Fallback for old bitwarden users without a key // Fallback for old Bitwarden users without a key
createIntegrityValues(userId) createIntegrityValues(userId)
true 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") @Suppress("TooGenericExceptionCaught")
private fun createIntegrityValues(userId: String) { private fun createIntegrityValues(userId: String) {
val systemBiometricIntegritySource = settingsDiskSource val systemBiometricIntegritySource = settingsDiskSource
@ -90,12 +166,7 @@ class BiometricsEncryptionManagerImpl(
) )
try { try {
val keyGen = KeyGenerator.getInstance( createCipher(userId)
KeyProperties.KEY_ALGORITHM_AES,
ENCRYPTION_KEYSTORE_NAME,
)
keyGen.init(keyGenParameterSpec)
keyGen.generateKey()
} catch (e: Exception) { } catch (e: Exception) {
// Catch silently to allow biometrics to function on devices that are in // Catch silently to allow biometrics to function on devices that are in
// a state where key generation is not functioning // a state where key generation is not functioning

View file

@ -57,6 +57,7 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import javax.crypto.Cipher
/** /**
* The top level composable for the Vault Unlock screen. * The top level composable for the Vault Unlock screen.
@ -72,8 +73,8 @@ fun VaultUnlockScreen(
val context = LocalContext.current val context = LocalContext.current
val resources = context.resources val resources = context.resources
val onBiometricsUnlockClick: () -> Unit = remember(viewModel) { val onBiometricsUnlockSuccess: (cipher: Cipher?) -> Unit = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) } { viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(it)) }
} }
val onBiometricsLockOut: () -> Unit = remember(viewModel) { val onBiometricsLockOut: () -> Unit = remember(viewModel) {
{ viewModel.trySendAction(VaultUnlockAction.BiometricsLockOut) } { viewModel.trySendAction(VaultUnlockAction.BiometricsLockOut) }
@ -85,10 +86,17 @@ fun VaultUnlockScreen(
Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show()
} }
VaultUnlockEvent.PromptForBiometrics -> { is VaultUnlockEvent.PromptForBiometrics -> {
biometricsManager.promptForBiometrics( biometricsManager.promptBiometrics(
onSuccess = onBiometricsUnlockClick, onSuccess = onBiometricsUnlockSuccess,
onCancel = {
// no-op
},
onError = {
// no-op
},
onLockOut = onBiometricsLockOut, onLockOut = onBiometricsLockOut,
cipher = event.cipher,
) )
} }
} }
@ -216,11 +224,8 @@ fun VaultUnlockScreen(
if (state.showBiometricLogin && biometricsManager.isBiometricsSupported) { if (state.showBiometricLogin && biometricsManager.isBiometricsSupported) {
BitwardenOutlinedButton( BitwardenOutlinedButton(
label = stringResource(id = R.string.use_biometrics_to_unlock), label = stringResource(id = R.string.use_biometrics_to_unlock),
onClick = { onClick = remember(viewModel) {
biometricsManager.promptForBiometrics( { viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) }
onSuccess = onBiometricsUnlockClick,
onLockOut = onBiometricsLockOut,
)
}, },
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .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,
)
}

View file

@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import javax.crypto.Cipher
import javax.inject.Inject import javax.inject.Inject
private const val KEY_STATE = "state" private const val KEY_STATE = "state"
@ -51,6 +52,7 @@ class VaultUnlockViewModel @Inject constructor(
val activeAccountSummary = userState.toActiveAccountSummary() val activeAccountSummary = userState.toActiveAccountSummary()
val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid( val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(
userId = userState.activeUserId, userId = userState.activeUserId,
cipher = biometricsEncryptionManager.getOrCreateCipher(userState.activeUserId),
) )
val vaultUnlockType = userState.activeAccount.vaultUnlockType val vaultUnlockType = userState.activeAccount.vaultUnlockType
val hasNoMasterPassword = trustedDevice?.hasMasterPassword == false val hasNoMasterPassword = trustedDevice?.hasMasterPassword == false
@ -73,6 +75,7 @@ class VaultUnlockViewModel @Inject constructor(
isBiometricsValid = isBiometricsValid, isBiometricsValid = isBiometricsValid,
showAccountMenu = VaultUnlockArgs(savedStateHandle).unlockType == UnlockType.STANDARD, showAccountMenu = VaultUnlockArgs(savedStateHandle).unlockType == UnlockType.STANDARD,
vaultUnlockType = vaultUnlockType, vaultUnlockType = vaultUnlockType,
userId = userState.activeUserId,
) )
}, },
) { ) {
@ -95,8 +98,13 @@ class VaultUnlockViewModel @Inject constructor(
} }
.launchIn(viewModelScope) .launchIn(viewModelScope)
if (state.showBiometricLogin) { val cipher = biometricsEncryptionManager.getOrCreateCipher(state.userId)
sendEvent(VaultUnlockEvent.PromptForBiometrics) if (state.showBiometricLogin && cipher != null) {
sendEvent(
VaultUnlockEvent.PromptForBiometrics(
cipher = cipher,
),
)
} }
} }
@ -111,6 +119,7 @@ class VaultUnlockViewModel @Inject constructor(
is VaultUnlockAction.SwitchAccountClick -> handleSwitchAccountClick(action) is VaultUnlockAction.SwitchAccountClick -> handleSwitchAccountClick(action)
VaultUnlockAction.BiometricsLockOut -> handleBiometricsLockOut() VaultUnlockAction.BiometricsLockOut -> handleBiometricsLockOut()
VaultUnlockAction.BiometricsUnlockClick -> handleBiometricsUnlockClick() VaultUnlockAction.BiometricsUnlockClick -> handleBiometricsUnlockClick()
is VaultUnlockAction.BiometricsUnlockSuccess -> handleBiometricsUnlockSuccess(action)
VaultUnlockAction.UnlockClick -> handleUnlockClick() VaultUnlockAction.UnlockClick -> handleUnlockClick()
is VaultUnlockAction.Internal -> handleInternalAction(action) is VaultUnlockAction.Internal -> handleInternalAction(action)
} }
@ -151,8 +160,22 @@ class VaultUnlockViewModel @Inject constructor(
} }
private fun handleBiometricsUnlockClick() { 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 val activeUserId = authRepository.activeUserId ?: return
if (!biometricsEncryptionManager.isBiometricIntegrityValid(activeUserId)) { if (!biometricsEncryptionManager.isBiometricIntegrityValid(activeUserId, action.cipher)) {
mutableStateFlow.update { it.copy(isBiometricsValid = false) } mutableStateFlow.update { it.copy(isBiometricsValid = false) }
return return
} }
@ -298,6 +321,7 @@ data class VaultUnlockState(
val isBiometricEnabled: Boolean, val isBiometricEnabled: Boolean,
val showAccountMenu: Boolean, val showAccountMenu: Boolean,
val vaultUnlockType: VaultUnlockType, val vaultUnlockType: VaultUnlockType,
val userId: String,
) : Parcelable { ) : Parcelable {
/** /**
@ -344,7 +368,7 @@ sealed class VaultUnlockEvent {
/** /**
* Prompts the user for biometrics unlock. * 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() 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. * The user has attempted to login with biometrics too many times and has been locked out.
*/ */

View file

@ -84,6 +84,16 @@ fun AccountSecurityScreen(
val state by viewModel.stateFlow.collectAsState() val state by viewModel.stateFlow.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val resources = context.resources 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 -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
AccountSecurityEvent.NavigateBack -> onNavigateBack() AccountSecurityEvent.NavigateBack -> onNavigateBack()
@ -108,6 +118,20 @@ fun AccountSecurityScreen(
intentManager.launchUri(event.url.toUri()) 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 -> { is AccountSecurityEvent.ShowToast -> {
Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show()
} }
@ -174,8 +198,18 @@ fun AccountSecurityScreen(
) )
UnlockWithBiometricsRow( UnlockWithBiometricsRow(
isChecked = state.isUnlockWithBiometricsEnabled, isChecked = state.isUnlockWithBiometricsEnabled,
onBiometricToggle = remember(viewModel) { showBiometricsPrompt = showBiometricsPrompt,
{ viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(it)) } onDisableBiometrics = remember(viewModel) {
{
viewModel.trySendAction(
AccountSecurityAction.UnlockWithBiometricToggle(
enabled = false,
),
)
}
},
onEnableBiometrics = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.EnableBiometricsClick) }
}, },
biometricsManager = biometricsManager, biometricsManager = biometricsManager,
modifier = Modifier modifier = Modifier
@ -349,12 +383,13 @@ private fun AccountSecurityDialogs(
@Composable @Composable
private fun UnlockWithBiometricsRow( private fun UnlockWithBiometricsRow(
isChecked: Boolean, isChecked: Boolean,
onBiometricToggle: (Boolean) -> Unit, showBiometricsPrompt: Boolean,
onDisableBiometrics: () -> Unit,
onEnableBiometrics: () -> Unit,
biometricsManager: BiometricsManager, biometricsManager: BiometricsManager,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (!biometricsManager.isBiometricsSupported) return if (!biometricsManager.isBiometricsSupported) return
var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) }
BitwardenWideSwitch( BitwardenWideSwitch(
modifier = modifier, modifier = modifier,
label = stringResource( label = stringResource(
@ -364,18 +399,9 @@ private fun UnlockWithBiometricsRow(
isChecked = isChecked || showBiometricsPrompt, isChecked = isChecked || showBiometricsPrompt,
onCheckedChange = { toggled -> onCheckedChange = { toggled ->
if (toggled) { if (toggled) {
showBiometricsPrompt = true onEnableBiometrics()
biometricsManager.promptBiometrics(
onSuccess = {
onBiometricToggle(true)
showBiometricsPrompt = false
},
onCancel = { showBiometricsPrompt = false },
onLockOut = { showBiometricsPrompt = false },
onError = { showBiometricsPrompt = false },
)
} else { } else {
onBiometricToggle(false) onDisableBiometrics()
} }
}, },
) )

View file

@ -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.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation 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.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import javax.crypto.Cipher
import javax.inject.Inject import javax.inject.Inject
private const val KEY_STATE = "state" private const val KEY_STATE = "state"
@ -34,21 +36,28 @@ private const val KEY_STATE = "state"
/** /**
* View model for the account security screen. * View model for the account security screen.
*/ */
@Suppress("TooManyFunctions") @Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel @HiltViewModel
class AccountSecurityViewModel @Inject constructor( class AccountSecurityViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository, private val vaultRepository: VaultRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val environmentRepository: EnvironmentRepository, private val environmentRepository: EnvironmentRepository,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
policyManager: PolicyManager, policyManager: PolicyManager,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : BaseViewModel<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>( ) : BaseViewModel<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>(
initialState = savedStateHandle[KEY_STATE] initialState = savedStateHandle[KEY_STATE] ?: run {
?: AccountSecurityState( val userId = requireNotNull(authRepository.userStateFlow.value).activeUserId
val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(
userId = userId,
cipher = biometricsEncryptionManager.getOrCreateCipher(userId),
)
AccountSecurityState(
dialog = null, dialog = null,
fingerprintPhrase = "".asText(), // This will be filled in dynamically fingerprintPhrase = "".asText(), // This will be filled in dynamically
isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled &&
isBiometricsValid,
isUnlockWithPasswordEnabled = authRepository isUnlockWithPasswordEnabled = authRepository
.userStateFlow .userStateFlow
.value .value
@ -56,11 +65,13 @@ class AccountSecurityViewModel @Inject constructor(
?.trustedDevice ?.trustedDevice
?.hasMasterPassword != false, ?.hasMasterPassword != false,
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled, isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
userId = userId,
vaultTimeout = settingsRepository.vaultTimeout, vaultTimeout = settingsRepository.vaultTimeout,
vaultTimeoutAction = settingsRepository.vaultTimeoutAction, vaultTimeoutAction = settingsRepository.vaultTimeoutAction,
vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyMinutes = null,
vaultTimeoutPolicyAction = null, vaultTimeoutPolicyAction = null,
), )
},
) { ) {
private val webSettingsUrl: String private val webSettingsUrl: String
get() { get() {
@ -104,6 +115,7 @@ class AccountSecurityViewModel @Inject constructor(
AccountSecurityAction.ConfirmLogoutClick -> handleConfirmLogoutClick() AccountSecurityAction.ConfirmLogoutClick -> handleConfirmLogoutClick()
AccountSecurityAction.DeleteAccountClick -> handleDeleteAccountClick() AccountSecurityAction.DeleteAccountClick -> handleDeleteAccountClick()
AccountSecurityAction.DismissDialog -> handleDismissDialog() AccountSecurityAction.DismissDialog -> handleDismissDialog()
AccountSecurityAction.EnableBiometricsClick -> handleEnableBiometricsClick()
AccountSecurityAction.FingerPrintLearnMoreClick -> handleFingerPrintLearnMoreClick() AccountSecurityAction.FingerPrintLearnMoreClick -> handleFingerPrintLearnMoreClick()
AccountSecurityAction.LockNowClick -> handleLockNowClick() AccountSecurityAction.LockNowClick -> handleLockNowClick()
AccountSecurityAction.LogoutClick -> handleLogoutClick() AccountSecurityAction.LogoutClick -> handleLogoutClick()
@ -144,6 +156,17 @@ class AccountSecurityViewModel @Inject constructor(
mutableStateFlow.update { it.copy(dialog = null) } 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() { private fun handleFingerPrintLearnMoreClick() {
sendEvent(AccountSecurityEvent.NavigateToFingerprintPhrase) sendEvent(AccountSecurityEvent.NavigateToFingerprintPhrase)
} }
@ -345,6 +368,7 @@ data class AccountSecurityState(
val isUnlockWithBiometricsEnabled: Boolean, val isUnlockWithBiometricsEnabled: Boolean,
val isUnlockWithPasswordEnabled: Boolean, val isUnlockWithPasswordEnabled: Boolean,
val isUnlockWithPinEnabled: Boolean, val isUnlockWithPinEnabled: Boolean,
val userId: String,
val vaultTimeout: VaultTimeout, val vaultTimeout: VaultTimeout,
val vaultTimeoutAction: VaultTimeoutAction, val vaultTimeoutAction: VaultTimeoutAction,
val vaultTimeoutPolicyMinutes: Int?, val vaultTimeoutPolicyMinutes: Int?,
@ -423,6 +447,13 @@ sealed class AccountSecurityEvent {
*/ */
data class NavigateToChangeMasterPassword(val url: String) : 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]. * Displays a toast with the given [Text].
*/ */
@ -466,6 +497,11 @@ sealed class AccountSecurityAction {
*/ */
data object DismissDialog : AccountSecurityAction() data object DismissDialog : AccountSecurityAction()
/**
* The user clicked to enable biometrics.
*/
data object EnableBiometricsClick : AccountSecurityAction()
/** /**
* User clicked fingerprint phrase. * User clicked fingerprint phrase.
*/ */

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.manager.biometrics package com.x8bit.bitwarden.ui.platform.manager.biometrics
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import javax.crypto.Cipher
/** /**
* Interface to manage biometrics within the app. * Interface to manage biometrics within the app.
@ -13,12 +14,13 @@ interface BiometricsManager {
val isBiometricsSupported: Boolean val isBiometricsSupported: Boolean
/** /**
* Display a prompt for biometrics. * Display a prompt for setting up or verifying biometrics.
*/ */
fun promptBiometrics( fun promptBiometrics(
onSuccess: () -> Unit, onSuccess: (cipher: Cipher?) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
onLockOut: () -> Unit, onLockOut: () -> Unit,
onError: () -> Unit, onError: () -> Unit,
cipher: Cipher,
) )
} }

View file

@ -8,6 +8,7 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import javax.crypto.Cipher
/** /**
* Default implementation of the [BiometricsManager] to manage biometrics within the app. * Default implementation of the [BiometricsManager] to manage biometrics within the app.
@ -35,10 +36,11 @@ class BiometricsManagerImpl(
} }
override fun promptBiometrics( override fun promptBiometrics(
onSuccess: () -> Unit, onSuccess: (cipher: Cipher?) -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
onLockOut: () -> Unit, onLockOut: () -> Unit,
onError: () -> Unit, onError: () -> Unit,
cipher: Cipher,
) { ) {
val biometricPrompt = BiometricPrompt( val biometricPrompt = BiometricPrompt(
fragmentActivity, fragmentActivity,
@ -46,7 +48,7 @@ class BiometricsManagerImpl(
object : BiometricPrompt.AuthenticationCallback() { object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded( override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult, result: BiometricPrompt.AuthenticationResult,
) = onSuccess() ) = onSuccess(result.cryptoObject?.cipher)
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
when (errorCode) { when (errorCode) {
@ -84,6 +86,6 @@ class BiometricsManagerImpl(
.setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG) .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG)
.build() .build()
biometricPrompt.authenticate(promptInfo) biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
} }
} }

View file

@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import javax.crypto.Cipher
class VaultUnlockScreenTest : BaseComposeTest() { class VaultUnlockScreenTest : BaseComposeTest() {
@ -51,7 +52,7 @@ class VaultUnlockScreenTest : BaseComposeTest() {
every { eventFlow } returns mutableEventFlow every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow every { stateFlow } returns mutableStateFlow
} }
private val captureBiometricsSuccess = slot<() -> Unit>() private val captureBiometricsSuccess = slot<(cipher: Cipher?) -> Unit>()
private val captureBiometricsLockOut = slot<() -> Unit>() private val captureBiometricsLockOut = slot<() -> Unit>()
private val biometricsManager: BiometricsManager = mockk { private val biometricsManager: BiometricsManager = mockk {
every { isBiometricsSupported } returns true every { isBiometricsSupported } returns true
@ -61,6 +62,7 @@ class VaultUnlockScreenTest : BaseComposeTest() {
onCancel = any(), onCancel = any(),
onLockOut = capture(captureBiometricsLockOut), onLockOut = capture(captureBiometricsLockOut),
onError = any(), onError = any(),
cipher = CIPHER,
) )
} just runs } just runs
} }
@ -76,18 +78,37 @@ class VaultUnlockScreenTest : BaseComposeTest() {
} }
@Test @Test
fun `on PromptForBiometrics should call launchUri on intentManager`() { fun `on PromptForBiometrics should call promptBiometrics on biometricsManager`() {
mutableEventFlow.tryEmit(VaultUnlockEvent.PromptForBiometrics) mutableEventFlow.tryEmit(VaultUnlockEvent.PromptForBiometrics(CIPHER))
verify { verify(exactly = 1) {
biometricsManager.promptBiometrics( biometricsManager.promptBiometrics(
onSuccess = any(), onSuccess = any(),
onCancel = any(), onCancel = any(),
onError = any(), onError = any(),
onLockOut = 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 @Test
fun `account icon click should show the account switcher`() { fun `account icon click should show the account switcher`() {
composeTestRule.assertSwitcherIsNotDisplayed( composeTestRule.assertSwitcherIsNotDisplayed(
@ -408,32 +429,17 @@ class VaultUnlockScreenTest : BaseComposeTest() {
} }
} }
@Suppress("MaxLineLength")
@Test @Test
fun `unlock with biometrics click should send BiometricsUnlockClick on biometrics authentication success`() { fun `unlock with biometrics click should send BiometricsUnlockClick`() {
composeTestRule composeTestRule
.onNodeWithText("Use biometrics to unlock") .onNodeWithText("Use biometrics to unlock")
.performScrollTo() .performScrollTo()
.performClick() .performClick()
captureBiometricsSuccess.captured()
verify(exactly = 1) { verify(exactly = 1) {
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) 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 @Test
fun `account button should update according to state`() { fun `account button should update according to state`() {
mutableStateFlow.update { it.copy(showAccountMenu = true) } mutableStateFlow.update { it.copy(showAccountMenu = true) }
@ -490,6 +496,7 @@ private val ACCOUNT_SUMMARIES = listOf(
LOCKED_ACCOUNT_SUMMARY, LOCKED_ACCOUNT_SUMMARY,
) )
private val CIPHER = mockk<Cipher>()
private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
accountSummaries = ACCOUNT_SUMMARIES, accountSummaries = ACCOUNT_SUMMARIES,
avatarColorString = "0000FF", avatarColorString = "0000FF",
@ -502,5 +509,6 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
isBiometricsValid = true, isBiometricsValid = true,
isBiometricEnabled = true, isBiometricEnabled = true,
showAccountMenu = true, showAccountMenu = true,
userId = ACTIVE_ACCOUNT_SUMMARY.userId,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
) )

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import javax.crypto.Cipher
@Suppress("LargeClass") @Suppress("LargeClass")
class VaultUnlockViewModelTest : BaseViewModelTest() { class VaultUnlockViewModelTest : BaseViewModelTest() {
@ -52,7 +53,19 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
every { lockVault(any()) } just runs every { lockVault(any()) } just runs
} }
private val encryptionManager: BiometricsEncryptionManager = mockk { 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 @Test
@ -64,7 +77,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel(state = initialState) val viewModel = createViewModel(state = initialState)
viewModel.eventFlow.test { 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`() { fun `initial state should be correct when not set`() {
val viewModel = createViewModel() val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
verify { encryptionManager.getOrCreateCipher(USER_ID) }
} }
@Test @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 @Test
fun `on AddAccountClick should set hasPendingAccountAddition to true on the AuthRepository`() { fun `on AddAccountClick should set hasPendingAccountAddition to true on the AuthRepository`() {
val viewModel = createViewModel() val viewModel = createViewModel()
@ -649,7 +689,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @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) val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)),
@ -659,7 +699,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
vaultRepository.unlockVaultWithBiometrics() vaultRepository.unlockVaultWithBiometrics()
} returns VaultUnlockResult.AuthenticationError } returns VaultUnlockResult.AuthenticationError
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
assertEquals( assertEquals(
initialState.copy( initialState.copy(
@ -676,7 +716,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @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) val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)),
@ -686,7 +726,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
vaultRepository.unlockVaultWithBiometrics() vaultRepository.unlockVaultWithBiometrics()
} returns VaultUnlockResult.GenericError } returns VaultUnlockResult.GenericError
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
assertEquals( assertEquals(
initialState.copy( initialState.copy(
@ -703,7 +743,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @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) val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)),
@ -713,7 +753,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
vaultRepository.unlockVaultWithBiometrics() vaultRepository.unlockVaultWithBiometrics()
} returns VaultUnlockResult.InvalidStateError } returns VaultUnlockResult.InvalidStateError
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
assertEquals( assertEquals(
initialState.copy( initialState.copy(
@ -729,7 +769,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
} }
@Test @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) val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)),
@ -739,7 +779,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
vaultRepository.unlockVaultWithBiometrics() vaultRepository.unlockVaultWithBiometrics()
} returns VaultUnlockResult.Success } returns VaultUnlockResult.Success
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
assertEquals( assertEquals(
initialState.copy(dialog = null), initialState.copy(dialog = null),
@ -751,7 +791,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
} }
@Test @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) val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true)
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)),
@ -762,7 +802,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
vaultRepository.unlockVaultWithBiometrics() vaultRepository.unlockVaultWithBiometrics()
} coAnswers { resultFlow.first() } } coAnswers { resultFlow.first() }
viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockSuccess(CIPHER))
assertEquals( assertEquals(
initialState.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading), 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( private fun createViewModel(
state: VaultUnlockState? = null, state: VaultUnlockState? = null,
unlockType: UnlockType = UnlockType.STANDARD, unlockType: UnlockType = UnlockType.STANDARD,
@ -800,6 +865,8 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
) )
} }
private val CIPHER = mockk<Cipher>()
private const val USER_ID: String = "activeUserId"
private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
accountSummaries = listOf( accountSummaries = listOf(
AccountSummary( AccountSummary(
@ -823,6 +890,7 @@ private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
isBiometricsValid = true, isBiometricsValid = true,
isBiometricEnabled = false, isBiometricEnabled = false,
showAccountMenu = true, showAccountMenu = true,
userId = USER_ID,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
) )
@ -835,7 +903,7 @@ private val TRUSTED_DEVICE: UserState.TrustedDevice = UserState.TrustedDevice(
) )
private val DEFAULT_ACCOUNT = UserState.Account( private val DEFAULT_ACCOUNT = UserState.Account(
userId = "activeUserId", userId = USER_ID,
name = "Active User", name = "Active User",
email = "active@bitwarden.com", email = "active@bitwarden.com",
environment = Environment.Us, environment = Environment.Us,
@ -851,6 +919,6 @@ private val DEFAULT_ACCOUNT = UserState.Account(
) )
private val DEFAULT_USER_STATE = UserState( private val DEFAULT_USER_STATE = UserState(
activeUserId = "activeUserId", activeUserId = USER_ID,
accounts = listOf(DEFAULT_ACCOUNT), accounts = listOf(DEFAULT_ACCOUNT),
) )

View file

@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import javax.crypto.Cipher
@Suppress("LargeClass") @Suppress("LargeClass")
class AccountSecurityScreenTest : BaseComposeTest() { class AccountSecurityScreenTest : BaseComposeTest() {
@ -50,7 +51,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
every { startActivity(any()) } just runs every { startActivity(any()) } just runs
every { startApplicationDetailsSettingsActivity() } 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 captureBiometricsCancel = slot<() -> Unit>()
private val captureBiometricsLockOut = slot<() -> Unit>() private val captureBiometricsLockOut = slot<() -> Unit>()
private val captureBiometricsError = slot<() -> Unit>() private val captureBiometricsError = slot<() -> Unit>()
@ -62,6 +63,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
onCancel = capture(captureBiometricsCancel), onCancel = capture(captureBiometricsCancel),
onLockOut = capture(captureBiometricsLockOut), onLockOut = capture(captureBiometricsLockOut),
onError = capture(captureBiometricsError), onError = capture(captureBiometricsError),
cipher = CIPHER,
) )
} just runs } just runs
} }
@ -108,8 +110,9 @@ class AccountSecurityScreenTest : BaseComposeTest() {
verify { viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick) } verify { viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick) }
} }
@Suppress("MaxLineLength")
@Test @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 composeTestRule
.onNodeWithText("Unlock with Biometrics") .onNodeWithText("Unlock with Biometrics")
.performScrollTo() .performScrollTo()
@ -118,17 +121,24 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.onNodeWithText("Unlock with Biometrics") .onNodeWithText("Unlock with Biometrics")
.performScrollTo() .performScrollTo()
.performClick() .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 composeTestRule
.onNodeWithText("Unlock with Biometrics") .onNodeWithText("Unlock with Biometrics")
.performScrollTo() .performScrollTo()
.assertIsOn() .assertIsOn()
captureBiometricsSuccess.captured()
composeTestRule composeTestRule
.onNodeWithText("Unlock with Biometrics") .onNodeWithText("Unlock with Biometrics")
.performScrollTo() .performScrollTo()
.assertIsOff() .performClick()
verify(exactly = 1) { verify(exactly = 1) {
viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(true)) viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(false))
} }
} }
@ -138,10 +148,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.onNodeWithText("Unlock with Biometrics") .onNodeWithText("Unlock with Biometrics")
.performScrollTo() .performScrollTo()
.assertIsOff() .assertIsOff()
composeTestRule mutableEventFlow.tryEmit(AccountSecurityEvent.ShowBiometricsPrompt(CIPHER))
.onNodeWithText("Unlock with Biometrics")
.performScrollTo()
.performClick()
composeTestRule composeTestRule
.onNodeWithText("Unlock with Biometrics") .onNodeWithText("Unlock with Biometrics")
.performScrollTo() .performScrollTo()
@ -162,10 +169,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.onNodeWithText("Unlock with Biometrics") .onNodeWithText("Unlock with Biometrics")
.performScrollTo() .performScrollTo()
.assertIsOff() .assertIsOff()
composeTestRule mutableEventFlow.tryEmit(AccountSecurityEvent.ShowBiometricsPrompt(CIPHER))
.onNodeWithText("Unlock with Biometrics")
.performScrollTo()
.performClick()
composeTestRule composeTestRule
.onNodeWithText("Unlock with Biometrics") .onNodeWithText("Unlock with Biometrics")
.performScrollTo() .performScrollTo()
@ -186,10 +190,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.onNodeWithText("Unlock with Biometrics") .onNodeWithText("Unlock with Biometrics")
.performScrollTo() .performScrollTo()
.assertIsOff() .assertIsOff()
composeTestRule mutableEventFlow.tryEmit(AccountSecurityEvent.ShowBiometricsPrompt(CIPHER))
.onNodeWithText("Unlock with Biometrics")
.performScrollTo()
.performClick()
composeTestRule composeTestRule
.onNodeWithText("Unlock with Biometrics") .onNodeWithText("Unlock with Biometrics")
.performScrollTo() .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 @Test
fun `on unlock with biometrics should be toggled on or off according to state`() { fun `on unlock with biometrics should be toggled on or off according to state`() {
composeTestRule.onNodeWithText("Unlock with Biometrics").assertIsOff() composeTestRule.onNodeWithText("Unlock with Biometrics").assertIsOff()
@ -1359,12 +1381,15 @@ class AccountSecurityScreenTest : BaseComposeTest() {
} }
} }
private val CIPHER = mockk<Cipher>()
private const val USER_ID: String = "activeUserId"
private val DEFAULT_STATE = AccountSecurityState( private val DEFAULT_STATE = AccountSecurityState(
dialog = null, dialog = null,
fingerprintPhrase = "fingerprint-placeholder".asText(), fingerprintPhrase = "fingerprint-placeholder".asText(),
isUnlockWithBiometricsEnabled = false, isUnlockWithBiometricsEnabled = false,
isUnlockWithPasswordEnabled = true, isUnlockWithPasswordEnabled = true,
isUnlockWithPinEnabled = false, isUnlockWithPinEnabled = false,
userId = USER_ID,
vaultTimeout = VaultTimeout.ThirtyMinutes, vaultTimeout = VaultTimeout.ThirtyMinutes,
vaultTimeoutAction = VaultTimeoutAction.LOCK, vaultTimeoutAction = VaultTimeoutAction.LOCK,
vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyMinutes = null,

View file

@ -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.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult import com.x8bit.bitwarden.data.auth.repository.model.UserFingerprintResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState 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.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@ -36,6 +37,7 @@ import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import javax.crypto.Cipher
class AccountSecurityViewModelTest : BaseViewModelTest() { class AccountSecurityViewModelTest : BaseViewModelTest() {
@ -53,6 +55,9 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT)
} }
private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>() private val mutableActivePolicyFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk {
every { createCipher(DEFAULT_USER_STATE.activeUserId) } returns CIPHER
}
private val policyManager: PolicyManager = mockk { private val policyManager: PolicyManager = mockk {
every { every {
getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT) getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
@ -69,11 +74,27 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
@Test @Test
fun `initial state should be correct when saved state is not set`() { fun `initial state should be correct when saved state is not set`() {
every { settingsRepository.isUnlockWithPinEnabled } returns true 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) val viewModel = createViewModel(initialState = null)
assertEquals( assertEquals(
DEFAULT_STATE.copy(isUnlockWithPinEnabled = true), DEFAULT_STATE.copy(isUnlockWithPinEnabled = true),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
verify {
biometricsEncryptionManager.getOrCreateCipher(DEFAULT_USER_STATE.activeUserId)
biometricsEncryptionManager.isBiometricIntegrityValid(
userId = DEFAULT_USER_STATE.activeUserId,
cipher = CIPHER,
)
}
coVerify { settingsRepository.getUserFingerprint() } 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 @Test
fun `on UnlockWithBiometricToggle false should call clearBiometricsKey and update the state`() = fun `on UnlockWithBiometricToggle false should call clearBiometricsKey and update the state`() =
runTest { runTest {
@ -547,12 +581,14 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
vaultRepository: VaultRepository = this.vaultRepository, vaultRepository: VaultRepository = this.vaultRepository,
environmentRepository: EnvironmentRepository = this.fakeEnvironmentRepository, environmentRepository: EnvironmentRepository = this.fakeEnvironmentRepository,
settingsRepository: SettingsRepository = this.settingsRepository, settingsRepository: SettingsRepository = this.settingsRepository,
biometricsEncryptionManager: BiometricsEncryptionManager = this.biometricsEncryptionManager,
policyManager: PolicyManager = this.policyManager, policyManager: PolicyManager = this.policyManager,
): AccountSecurityViewModel = AccountSecurityViewModel( ): AccountSecurityViewModel = AccountSecurityViewModel(
authRepository = authRepository, authRepository = authRepository,
vaultRepository = vaultRepository, vaultRepository = vaultRepository,
settingsRepository = settingsRepository, settingsRepository = settingsRepository,
environmentRepository = environmentRepository, environmentRepository = environmentRepository,
biometricsEncryptionManager = biometricsEncryptionManager,
policyManager = policyManager, policyManager = policyManager,
savedStateHandle = SavedStateHandle().apply { savedStateHandle = SavedStateHandle().apply {
set("state", initialState) set("state", initialState)
@ -560,20 +596,9 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
) )
} }
private val CIPHER = mockk<Cipher>()
private const val FINGERPRINT: String = "fingerprint" 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( private val DEFAULT_USER_STATE = UserState(
activeUserId = "activeUserId", activeUserId = "activeUserId",
accounts = listOf( 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,
)