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