BIT-2170: Fix biometric bypass (#1324)

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

View file

@ -1,9 +1,25 @@
package com.x8bit.bitwarden.data.platform.manager
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
}

View file

@ -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

View file

@ -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,
)
}

View file

@ -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.
*/

View file

@ -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()
}
},
)

View file

@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.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.
*/

View file

@ -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,
)
}

View file

@ -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))
}
}

View file

@ -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,
)

View file

@ -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),
)

View file

@ -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,

View file

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.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,
)