mirror of
https://github.com/bitwarden/android.git
synced 2025-02-16 11:59:57 +03:00
BIT-2170: Fix biometric bypass (#1324)
This commit is contained in:
parent
4880d0b89d
commit
f3f35511a4
12 changed files with 449 additions and 137 deletions
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
Loading…
Add table
Reference in a new issue