PM-37902: feat: handle key rotations (#7420)

This commit is contained in:
David Perez authored and GitHub committed 2026-09-24 15:03:12 +00:00
1 parent bf85c77ff5
commit 1f0c69aa5b
32 files changed
+2039 -20

No files matched your search

@@ -166,14 +166,28 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.UserNotification>(
string = notification.payload,
)
.takeUnless {
.takeUnless { it.userId == null }
?.takeUnless {
featureFlagManager.getFeatureFlag(FlagKey.NoLogoutOnKdfChange) &&
it.pushNotificationLogOutReason ==
PushNotificationLogOutReason.KDF_CHANGE
it.logOutReason == PushNotificationLogOutReason.KDF_CHANGE
}
?.userId
?.let {
mutableLogoutSharedFlow.tryEmit(NotificationLogoutData(userId = it))
val userId = requireNotNull(it.userId)
when (it.logOutReason) {
PushNotificationLogOutReason.KEY_ROTATION -> {
// We do not want to logout, we need to sync to help
// facilitate the key rotation.
mutableFullSyncSharedFlow.tryEmit(userId)
}
PushNotificationLogOutReason.KDF_CHANGE,
null,
-> {
mutableLogoutSharedFlow.tryEmit(
NotificationLogoutData(userId = userId),
)
}
}
}
}
@@ -71,6 +71,8 @@ import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManagerImpl
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.keyrotation.KeyRotationManager
import com.x8bit.bitwarden.data.platform.manager.keyrotation.KeyRotationManagerImpl
import com.x8bit.bitwarden.data.platform.manager.log.SettingsLogManager
import com.x8bit.bitwarden.data.platform.manager.log.SettingsLogManagerImpl
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
@@ -98,6 +100,7 @@ import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
@@ -523,4 +526,14 @@ object PlatformManagerModule {
accessibilityEnabledManager = accessibilityEnabledManager,
browserThirdPartyAutofillEnabledManager = browserThirdPartyAutofillEnabledManager,
)
@Provides
@Singleton
fun provideKeyRotationManager(
authDiskSource: AuthDiskSource,
vaultSdkSource: VaultSdkSource,
): KeyRotationManager = KeyRotationManagerImpl(
authDiskSource = authDiskSource,
vaultSdkSource = vaultSdkSource,
)
}
@@ -0,0 +1,28 @@
package com.x8bit.bitwarden.data.platform.manager.keyrotation
import kotlinx.coroutines.flow.Flow
/**
* A manager for rotating user keys stored to disk.
*/
interface KeyRotationManager {
/**
* Emits when the user needs to be prompted to rotate the Biometrics Key.
*/
val shouldRotateBiometricKey: Flow<Unit>
/**
* Checks if the Authenticator Sync key needs to be rotated ands does so if needed.
*/
suspend fun rotateAuthenticatorSyncKey(userId: String)
/**
* Checks if the Auto-unlock key needs to be rotated ands does so if needed.
*/
suspend fun rotateAutoUnlockKey(userId: String)
/**
* Checks if the Biometrics key needs to be rotated ands prompts the user to do so if needed.
*/
suspend fun rotateBiometricsKey(userId: String, decryptedBiometricsUserKey: String)
}
@@ -0,0 +1,75 @@
package com.x8bit.bitwarden.data.platform.manager.keyrotation
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.flatMap
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import kotlinx.coroutines.flow.Flow
import timber.log.Timber
/**
* THe default implementation of the [KeyRotationManager].
*/
internal class KeyRotationManagerImpl(
private val authDiskSource: AuthDiskSource,
private val vaultSdkSource: VaultSdkSource,
) : KeyRotationManager {
private val mutableShouldRotateBiometricKey = bufferedMutableSharedFlow<Unit>()
override val shouldRotateBiometricKey: Flow<Unit> = mutableShouldRotateBiometricKey
override suspend fun rotateAuthenticatorSyncKey(userId: String) {
authDiskSource
.getAuthenticatorSyncUnlockKey(userId = userId)
?.let { getRotatedKeyIfNecessary(userId = userId, keyToRotate = it) }
?.let {
Timber.i("Rotating Authenticator Sync Unlock Key")
// Store the new key to disk for future usage.
authDiskSource.storeAuthenticatorSyncUnlockKey(
userId = userId,
authenticatorSyncUnlockKey = it,
)
}
}
override suspend fun rotateAutoUnlockKey(userId: String) {
authDiskSource
.getUserAutoUnlockKey(userId = userId)
?.let { getRotatedKeyIfNecessary(userId = userId, keyToRotate = it) }
?.let {
Timber.i("Rotating Auto Unlock Key")
// Store the new key to disk for future usage.
authDiskSource.storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = it)
}
}
override suspend fun rotateBiometricsKey(userId: String, decryptedBiometricsUserKey: String) {
this
.getRotatedKeyIfNecessary(userId = userId, keyToRotate = decryptedBiometricsUserKey)
?.let { _ ->
Timber.i("Prompting to rotate Biometrics Key")
// We need user interaction to rotate this key, so we emit here to prompt the user.
mutableShouldRotateBiometricKey.emit(Unit)
}
}
/**
* Returns the new key that should be used if the provided [keyToRotate] needs to be rotated.
* `null` is returned if the key should not or cannot be rotated.
*/
private suspend fun getRotatedKeyIfNecessary(
userId: String,
keyToRotate: String,
): String? =
vaultSdkSource
.getKeyId(userKey = keyToRotate)
.flatMap { keyId ->
vaultSdkSource
.getUserEncryptionKey(userId = userId)
.flatMap { encryptedUserKey ->
vaultSdkSource
.getKeyId(userKey = encryptedUserKey)
.map { encryptedUserKey.takeIf { _ -> keyId != it } }
}
}
.getOrNull()
}
@@ -58,7 +58,7 @@ sealed class NotificationPayload {
val date: Instant?,
@JsonNames("Reason", "reason")
val pushNotificationLogOutReason: PushNotificationLogOutReason?,
val logOutReason: PushNotificationLogOutReason?,
) : NotificationPayload()
/**
@@ -12,6 +12,9 @@ import kotlinx.serialization.Serializable
enum class PushNotificationLogOutReason {
@SerialName("0")
KDF_CHANGE,
@SerialName("1")
KEY_ROTATION,
}
@Keep
@@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toV2UpgradeToken
import com.x8bit.bitwarden.data.vault.repository.util.toVaultUnlockResult
/**
@@ -151,7 +152,9 @@ class AuthenticatorBridgeRepositoryImpl(
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = decryptedUserKey,
),
upgradeToken = null,
upgradeToken = authDiskSource
.getV2UpgradeToken(userId = userId)
?.toV2UpgradeToken(),
),
)
.flatMap { result ->
@@ -7,6 +7,7 @@ import com.bitwarden.core.EnrollPinResponse
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.ReinitUserCryptoRequest
import com.bitwarden.core.UpdateKdfResponse
import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.crypto.Kdf
@@ -51,6 +52,11 @@ interface VaultSdkSource {
*/
fun clearCrypto(userId: String)
/**
* Returns the Key ID for the given raw user key.
*/
fun getKeyId(userKey: String): Result<String>
/**
* Gets the data to authenticate with trusted device encryption.
*
@@ -140,6 +146,15 @@ interface VaultSdkSource {
*/
suspend fun getUserFingerprint(userId: String): Result<String>
/**
* Attempts to reinitialize cryptography functionality for an individual user with the given
* [userId] for the Bitwarden SDK with a given [ReinitUserCryptoRequest].
*/
suspend fun reinitializeCrypto(
userId: String,
request: ReinitUserCryptoRequest,
): Result<Unit>
/**
* Attempts to initialize cryptography functionality for an individual user with the given
* [userId] for the Bitwarden SDK with a given [InitUserCryptoRequest].
@@ -9,6 +9,7 @@ import com.bitwarden.core.EncryptionSettingsException
import com.bitwarden.core.EnrollPinResponse
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.ReinitUserCryptoRequest
import com.bitwarden.core.UpdateKdfResponse
import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
@@ -72,6 +73,11 @@ class VaultSdkSourceImpl(
sdkClientManager.destroyClient(userId = userId)
}
override fun getKeyId(userKey: String): Result<String> = runCatchingWithLogs {
// This can never be null, the SDK team needs to update the return type to match.
requireNotNull(value = globalClient.crypto().getKeyIdForSymmetricKey(key = userKey))
}
override suspend fun getTrustDevice(
userId: String,
): Result<TrustDeviceResponse> = runCatchingWithLogs {
@@ -186,6 +192,13 @@ class VaultSdkSourceImpl(
.userFingerprint(fingerprintMaterial = userId)
}
override suspend fun reinitializeCrypto(
userId: String,
request: ReinitUserCryptoRequest,
): Result<Unit> = runCatchingWithLogs {
getClient(userId = userId).crypto().reinitUserCrypto(req = request)
}
override suspend fun initializeCrypto(
userId: String,
request: InitUserCryptoRequest,
@@ -35,6 +35,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.updateForcePasswordReset
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.keyrotation.KeyRotationManager
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
@@ -102,6 +103,7 @@ internal class VaultLockManagerImpl(
private val kdfManager: KdfManager,
private val pinProtectedUserKeyManager: PinProtectedUserKeyManager,
private val passwordPolicyManager: PasswordPolicyManager,
private val keyRotationManager: KeyRotationManager,
dispatcherManager: DispatcherManager,
context: Context,
) : VaultLockManager {
@@ -267,6 +269,8 @@ internal class VaultLockManagerImpl(
updateKdfIfNeeded(initUserCryptoMethod)
pinProtectedUserKeyManager.migratePinProtectedUserKeyIfNeeded(userId = userId)
setVaultToUnlocked(userId = userId)
keyRotationManager.rotateAutoUnlockKey(userId = userId)
keyRotationManager.rotateAuthenticatorSyncKey(userId = userId)
}
/**
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.ReinitUserCryptoRequest
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.combineDataStates
@@ -41,6 +42,7 @@ import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList
import com.x8bit.bitwarden.data.vault.repository.util.toV2UpgradeToken
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -75,7 +77,7 @@ private const val SYNC_IF_NECESSARY_DELAY_MIN: Long = 30L
* Default implementation of [VaultSyncManager].
*/
@Suppress("LongParameterList", "TooManyFunctions")
class VaultSyncManagerImpl(
internal class VaultSyncManagerImpl(
private val syncService: SyncService,
private val settingsDiskSource: SettingsDiskSource,
private val authDiskSource: AuthDiskSource,
@@ -328,10 +330,6 @@ class VaultSyncManagerImpl(
authDiskSource.userState = authDiskSource.userState?.toUpdatedUserStateJson(
syncResponse = syncResponse,
)
authDiskSource.storeV2UpgradeToken(
userId = userId,
v2UpgradeToken = syncResponse.userDecryption?.v2UpgradeToken,
)
unlockVaultForOrganizationsIfNecessary(syncResponse = syncResponse)
storeProfileData(syncResponse = syncResponse)
@@ -379,20 +377,33 @@ class VaultSyncManagerImpl(
)
}
private fun storeProfileData(
private suspend fun storeProfileData(
syncResponse: SyncResponseJson,
) {
val profile = syncResponse.profile
val userId = profile.id
authDiskSource.apply {
val accountCryptographicState = profile.privateKeyOrNull()?.let {
profile.accountKeys.toAccountCryptographicState(privateKey = it)
}
val v2UpgradeToken = syncResponse.userDecryption?.v2UpgradeToken
storeAccountCryptographicState(
userId = userId,
accountCryptographicState = profile.privateKeyOrNull()?.let {
profile.accountKeys.toAccountCryptographicState(
privateKey = it,
)
},
accountCryptographicState = accountCryptographicState,
)
storeV2UpgradeToken(userId = userId, v2UpgradeToken = v2UpgradeToken)
if (accountCryptographicState != null &&
v2UpgradeToken != null &&
vaultLockManager.isVaultUnlocked(userId = userId)
) {
vaultSdkSource.reinitializeCrypto(
userId = userId,
request = ReinitUserCryptoRequest(
accountCryptographicState = accountCryptographicState,
upgradeToken = v2UpgradeToken.toV2UpgradeToken(),
),
)
}
storeOrganizationKeys(
userId = userId,
organizationKeys = profile.organizations
@@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.keyrotation.KeyRotationManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -186,6 +187,7 @@ object VaultManagerModule {
kdfManager: KdfManager,
pinProtectedUserKeyManager: PinProtectedUserKeyManager,
passwordPolicyManager: PasswordPolicyManager,
keyRotationManager: KeyRotationManager,
): VaultLockManager =
VaultLockManagerImpl(
context = context,
@@ -202,6 +204,7 @@ object VaultManagerModule {
kdfManager = kdfManager,
pinProtectedUserKeyManager = pinProtectedUserKeyManager,
passwordPolicyManager = passwordPolicyManager,
keyRotationManager = keyRotationManager,
)
@Provides
@@ -9,6 +9,7 @@ import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.platform.manager.keyrotation.KeyRotationManager
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.FolderManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
@@ -33,6 +34,7 @@ import javax.crypto.Cipher
interface VaultRepository :
CipherManager,
FolderManager,
KeyRotationManager,
SendManager,
VaultLockManager,
VaultSyncManager {
@@ -24,6 +24,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.keyrotation.KeyRotationManager
import com.x8bit.bitwarden.data.platform.util.isActive
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@@ -84,11 +85,13 @@ internal class VaultRepositoryImpl(
private val vaultSyncManager: VaultSyncManager,
private val credentialExchangeImportManager: CredentialExchangeImportManager,
private val pinProtectedUserKeyManager: PinProtectedUserKeyManager,
private val keyRotationManager: KeyRotationManager,
private val featureFlagManager: FeatureFlagManager,
dispatcherManager: DispatcherManager,
) : VaultRepository,
CipherManager by cipherManager,
FolderManager by folderManager,
KeyRotationManager by keyRotationManager,
SendManager by sendManager,
VaultLockManager by vaultLockManager,
VaultSyncManager by vaultSyncManager {
@@ -338,6 +341,10 @@ internal class VaultRepositoryImpl(
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = userId,
)
keyRotationManager.rotateBiometricsKey(
userId = userId,
decryptedBiometricsUserKey = decryptedUserKey,
)
}
}
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.repository.di
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.keyrotation.KeyRotationManager
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.CipherManager
@@ -43,6 +44,7 @@ object VaultRepositoryModule {
vaultSyncManager: VaultSyncManager,
credentialExchangeImportManager: CredentialExchangeImportManager,
pinProtectedUserKeyManager: PinProtectedUserKeyManager,
keyRotationManager: KeyRotationManager,
featureFlagManager: FeatureFlagManager,
): VaultRepository = VaultRepositoryImpl(
vaultDiskSource = vaultDiskSource,
@@ -57,6 +59,7 @@ object VaultRepositoryModule {
vaultSyncManager = vaultSyncManager,
credentialExchangeImportManager = credentialExchangeImportManager,
pinProtectedUserKeyManager = pinProtectedUserKeyManager,
keyRotationManager = keyRotationManager,
featureFlagManager = featureFlagManager,
)
}
@@ -0,0 +1,40 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.platform.feature.biometrics
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the Update Biometrics screen.
*/
@OmitFromCoverage
@Serializable
data object UpdateBiometricsRoute
/**
* Add the Update Biometrics screen to the nav graph.
*/
fun NavGraphBuilder.updateBiometricsDestination(
onDismiss: () -> Unit,
onSplashScreenRemoved: () -> Unit,
) {
composableWithSlideTransitions<UpdateBiometricsRoute> {
UpdateBiometricsScreen(onDismiss = onDismiss)
// If we are displaying the update biometrics screen, then we can just
// hide the splash screen.
onSplashScreenRemoved()
}
}
/**
* Navigate to the Update Biometrics screen.
*/
fun NavController.navigateToUpdateBiometrics() {
this.navigate(route = UpdateBiometricsRoute) {
launchSingleTop = true
}
}
@@ -0,0 +1,208 @@
package com.x8bit.bitwarden.ui.platform.feature.biometrics
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
/**
* Top-level composable for the Update Biometrics screen.
*/
@Composable
fun UpdateBiometricsScreen(
onDismiss: () -> Unit,
biometricsManager: BiometricsManager = LocalBiometricsManager.current,
viewModel: UpdateBiometricsViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
UpdateBiometricsEvent.NavigateBack -> onDismiss()
is UpdateBiometricsEvent.ShowBiometricsPrompt -> {
biometricsManager.promptBiometrics(
onSuccess = {
viewModel.trySendAction(
UpdateBiometricsAction.BiometricCipherReceived(cipher = it),
)
},
onCancel = { },
onLockOut = { },
onError = { },
cipher = event.cipher,
)
}
}
}
BackHandler { viewModel.trySendAction(UpdateBiometricsAction.TurnOffBiometricsClick) }
UpdateBiometricsDialogs(
dialogState = state.dialogState,
onDismissClick = { viewModel.trySendAction(UpdateBiometricsAction.DismissDialog) },
onConfirmTurnOffClick = {
viewModel.trySendAction(UpdateBiometricsAction.ConfirmTurnOffClick)
},
)
BitwardenScaffold(
contentWindowInsets = ScaffoldDefaults
.contentWindowInsets
.union(insets = WindowInsets.displayCutout)
.only(sides = WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
) {
UpdateBiometricsContent(
onConfirmBiometricsClick = {
viewModel.trySendAction(UpdateBiometricsAction.ConfirmBiometricsClick)
},
onTurnOffClick = {
viewModel.trySendAction(UpdateBiometricsAction.TurnOffBiometricsClick)
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun UpdateBiometricsDialogs(
dialogState: UpdateBiometricsState.DialogState?,
onDismissClick: () -> Unit,
onConfirmTurnOffClick: () -> Unit,
) {
when (dialogState) {
UpdateBiometricsState.DialogState.DisableBiometricsAndClose -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.turn_off_biometric_question),
message = stringResource(
id = BitwardenString.unlocking_will_require_your_master_password,
),
confirmButtonText = stringResource(id = BitwardenString.turn_off),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onDismissClick = onDismissClick,
onDismissRequest = onDismissClick,
onConfirmClick = onConfirmTurnOffClick,
confirmTextColor = BitwardenTheme.colorScheme.status.error,
)
}
is UpdateBiometricsState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialogState.message())
}
is UpdateBiometricsState.DialogState.Error -> {
BitwardenBasicDialog(
title = dialogState.title?.invoke(),
message = dialogState.message(),
onDismissRequest = onDismissClick,
)
}
null -> Unit
}
}
@Composable
private fun UpdateBiometricsContent(
onConfirmBiometricsClick: () -> Unit,
onTurnOffClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.verticalScroll(state = rememberScrollState()),
) {
Spacer(modifier = Modifier.height(height = 32.dp))
Image(
painter = rememberVectorPainter(id = BitwardenDrawable.ill_account_setup),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.standardHorizontalMargin()
.size(size = 100.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
Text(
text = stringResource(id = BitwardenString.keep_unlocking_with_biometrics),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
Text(
text = stringResource(id = BitwardenString.weve_made_a_security_update),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.confirm),
onClick = onConfirmBiometricsClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.turn_off),
onClick = onTurnOffClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@@ -0,0 +1,223 @@
package com.x8bit.bitwarden.ui.platform.feature.biometrics
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.crypto.Cipher
import javax.inject.Inject
/**
* ViewModel for the Update Biometrics screen.
*/
@HiltViewModel
class UpdateBiometricsViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val settingsRepository: SettingsRepository,
) : BaseViewModel<UpdateBiometricsState, UpdateBiometricsEvent, UpdateBiometricsAction>(
initialState = UpdateBiometricsState(
dialogState = null,
),
) {
override fun handleAction(action: UpdateBiometricsAction) {
when (action) {
is UpdateBiometricsAction.BiometricCipherReceived -> {
handleBiometricCipherReceived(action)
}
UpdateBiometricsAction.ConfirmBiometricsClick -> handleConfirmBiometricClick()
UpdateBiometricsAction.ConfirmTurnOffClick -> handleConfirmTurnOffClick()
UpdateBiometricsAction.DismissDialog -> handleDismissDialog()
UpdateBiometricsAction.TurnOffBiometricsClick -> handleTurnOffBiometricsClick()
is UpdateBiometricsAction.Internal -> handleInternalAction(action)
}
}
private fun handleInternalAction(action: UpdateBiometricsAction.Internal) {
when (action) {
is UpdateBiometricsAction.Internal.BiometricsKeyResultReceive -> {
handleBiometricsKeyResultReceive(action)
}
}
}
private fun handleBiometricsKeyResultReceive(
action: UpdateBiometricsAction.Internal.BiometricsKeyResultReceive,
) {
when (action.result) {
is BiometricsKeyResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = UpdateBiometricsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
),
)
}
}
BiometricsKeyResult.Success -> {
Timber.i("Rotated Biometrics Unlock Key")
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(UpdateBiometricsEvent.NavigateBack)
}
}
}
private fun handleBiometricCipherReceived(
action: UpdateBiometricsAction.BiometricCipherReceived,
) {
mutableStateFlow.update {
it.copy(
dialogState = UpdateBiometricsState.DialogState.Loading(
message = BitwardenString.saving.asText(),
),
)
}
viewModelScope.launch {
val result = settingsRepository.setupBiometricsKey(cipher = action.cipher)
sendAction(UpdateBiometricsAction.Internal.BiometricsKeyResultReceive(result = result))
}
}
private fun handleConfirmBiometricClick() {
authRepository
.activeUserId
?.let {
authRepository.clearBiometrics(userId = it)
authRepository.createCipherOrNull(userId = it)
}
?.let { sendEvent(UpdateBiometricsEvent.ShowBiometricsPrompt(cipher = it)) }
?: run {
mutableStateFlow.update {
it.copy(
dialogState = UpdateBiometricsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
),
)
}
}
}
private fun handleConfirmTurnOffClick() {
mutableStateFlow.update { it.copy(dialogState = null) }
authRepository.userStateFlow.value?.let {
// Clear Biometrics
authRepository.clearBiometrics(userId = it.activeUserId)
val hasOtherUnlockMechanism = it.activeAccount.hasMasterPassword ||
it.activeAccount.vaultUnlockType == VaultUnlockType.PIN
if (!hasOtherUnlockMechanism) {
settingsRepository.vaultTimeoutAction = VaultTimeoutAction.LOGOUT
}
}
sendEvent(UpdateBiometricsEvent.NavigateBack)
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleTurnOffBiometricsClick() {
mutableStateFlow.update {
it.copy(dialogState = UpdateBiometricsState.DialogState.DisableBiometricsAndClose)
}
}
}
/**
* State for the Update Biometrics screen.
*/
data class UpdateBiometricsState(
val dialogState: DialogState?,
) {
/**
* Represents the dialogs available to this screen.
*/
sealed class DialogState {
/**
* Represents a dialog informing the user of the ramifications of skipping this process.
*/
data object DisableBiometricsAndClose : DialogState()
/**
* Represents an error dialog with an optional title and message.
*/
data class Error(
val title: Text?,
val message: Text,
) : DialogState()
/**
* Represents a loading dialog with a message.
*/
data class Loading(
val message: Text,
) : DialogState()
}
}
/**
* Events for the Update Biometrics screen.
*/
sealed class UpdateBiometricsEvent {
/**
* Navigate away from this screen.
*/
data object NavigateBack : UpdateBiometricsEvent()
/**
* Shows the prompt for Biometrics using with the given [cipher].
*/
data class ShowBiometricsPrompt(val cipher: Cipher) : UpdateBiometricsEvent()
}
/**
* Actions for the Update Biometrics screen.
*/
sealed class UpdateBiometricsAction {
/**
* The biometric cipher has been received
*/
data class BiometricCipherReceived(val cipher: Cipher) : UpdateBiometricsAction()
/**
* The user has clicked to dismiss the dialog.
*/
data object DismissDialog : UpdateBiometricsAction()
/**
* The user has clicked the confirm button.
*/
data object ConfirmBiometricsClick : UpdateBiometricsAction()
/**
* The user has clicked to confirm that they want to turn off Biometrics.
*/
data object ConfirmTurnOffClick : UpdateBiometricsAction()
/**
* The user has clicked the turn off button.
*/
data object TurnOffBiometricsClick : UpdateBiometricsAction()
/**
* Actions for internal use by the ViewModel.
*/
sealed class Internal : UpdateBiometricsAction() {
/**
* Indicates the biometrics key validation results has been received.
*/
data class BiometricsKeyResultReceive(val result: BiometricsKeyResult) : Internal()
}
}
@@ -9,6 +9,8 @@ import com.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.accessibilityDisclosureDestination
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.navigateToAccessibilityDisclosure
import com.x8bit.bitwarden.ui.platform.feature.biometrics.navigateToUpdateBiometrics
import com.x8bit.bitwarden.ui.platform.feature.biometrics.updateBiometricsDestination
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.localNetworkAccessDestination
@@ -50,6 +52,10 @@ fun OverlayNavScreen(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = onSplashScreenRemoved,
)
updateBiometricsDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = onSplashScreenRemoved,
)
}
}
@@ -60,6 +66,10 @@ private fun OverlayNavEventsEffect(
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
OverlayNavEvent.NavigateToUpdateBiometrics -> {
navController.navigateToUpdateBiometrics()
}
OverlayNavEvent.NavigateToCookieAcquisition -> {
navController.navigateToCookieAcquisition()
}
@@ -6,6 +6,7 @@ import com.bitwarden.ui.platform.base.DeferredBackgroundEvent
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNot
@@ -22,6 +23,7 @@ import javax.inject.Inject
class OverlayNavViewModel @Inject constructor(
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
networkPermissionManager: NetworkPermissionManager,
vaultRepository: VaultRepository,
settingsRepository: SettingsRepository,
) : BaseViewModel<Unit, OverlayNavEvent, OverlayNavAction>(initialState = Unit) {
init {
@@ -45,6 +47,12 @@ class OverlayNavViewModel @Inject constructor(
.map { OverlayNavAction.Internal.CookieAcquisitionReady }
.onEach(::sendAction)
.launchIn(viewModelScope)
vaultRepository
.shouldRotateBiometricKey
.map { OverlayNavAction.Internal.RotateBiometricsKey }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: OverlayNavAction) {
@@ -63,6 +71,8 @@ class OverlayNavViewModel @Inject constructor(
OverlayNavAction.Internal.LocalNetworkAccessRequired -> {
handleLocalNetworkAccessRequired()
}
OverlayNavAction.Internal.RotateBiometricsKey -> handleRotateBiometricsKey()
}
}
@@ -77,12 +87,21 @@ class OverlayNavViewModel @Inject constructor(
private fun handleLocalNetworkAccessRequired() {
sendEvent(OverlayNavEvent.NavigateToLocalNetworkAccess)
}
private fun handleRotateBiometricsKey() {
sendEvent(OverlayNavEvent.NavigateToUpdateBiometrics)
}
}
/**
* Models events for the overlay navigation screen.
*/
sealed class OverlayNavEvent {
/**
* Navigate to the Update Biometrics screen.
*/
data object NavigateToUpdateBiometrics : OverlayNavEvent(), DeferredBackgroundEvent
/**
* Navigate to the cookie acquisition screen.
*/
@@ -123,5 +142,10 @@ sealed class OverlayNavAction {
* Indicates that the accessibility disclosure needs to be displayed.
*/
data object AccessibilityDisclosureRequired : Internal()
/**
* Indicates that Biometrics needs to be re-initialized for key rotation.
*/
data object RotateBiometricsKey : Internal()
}
}
@@ -502,6 +502,13 @@ class FakeAuthDiskSource : AuthDiskSource {
assertEquals(twoFactorToken, storedTwoFactorTokens[email])
}
/**
* Assert that the [authenticatorSyncKey] was stored successfully using the [userId].
*/
fun assertAuthenticatorSyncKey(userId: String, authenticatorSyncKey: String?) {
assertEquals(authenticatorSyncKey, storedAuthenticationSyncKeys[userId])
}
/**
* Assert that the [userAutoUnlockKey] was stored successfully using the [userId].
*/
@@ -164,6 +164,66 @@ class PushManagerTest {
}
}
@Test
@Suppress("MaxLineLength")
fun `onMessageReceived with logout with kdf change as reason and disabled flag should emit to logoutFlow`() =
runTest {
every {
mockFeatureFlagManager.getFeatureFlag(FlagKey.NoLogoutOnKdfChange)
} returns false
pushManager.logoutFlow.test {
pushManager.onMessageReceived(LOGOUT_KDF_NOTIFICATION_MAP)
assertEquals(
NotificationLogoutData(userId = "078966a2-93c2-4618-ae2a-0a2394c88d37"),
awaitItem(),
)
}
}
@Test
@Suppress("MaxLineLength")
fun `onMessageReceived with logout with key rotation as reason should emit to fullSyncFlow`() =
runTest {
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(LOGOUT_KEY_ROTATION_NOTIFICATION_MAP)
assertEquals("078966a2-93c2-4618-ae2a-0a2394c88d37", awaitItem())
}
}
@Test
@Suppress("MaxLineLength")
fun `onMessageReceived with logout with key rotation as reason should not emit to logoutFlow`() =
runTest {
pushManager.logoutFlow.test {
pushManager.onMessageReceived(LOGOUT_KEY_ROTATION_NOTIFICATION_MAP)
expectNoEvents()
}
}
@Test
@Suppress("MaxLineLength")
fun `onMessageReceived with logout with key rotation as reason and enabled flag should emit to fullSyncFlow`() =
runTest {
every {
mockFeatureFlagManager.getFeatureFlag(FlagKey.NoLogoutOnKdfChange)
} returns true
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(LOGOUT_KEY_ROTATION_NOTIFICATION_MAP)
assertEquals("078966a2-93c2-4618-ae2a-0a2394c88d37", awaitItem())
}
}
@Test
fun `onMessageReceived with logout with null user ID should not emit to logoutFlow`() =
runTest {
pushManager.logoutFlow.test {
pushManager.onMessageReceived(LOGOUT_NULL_USER_ID_NOTIFICATION_MAP)
expectNoEvents()
}
}
@Test
fun `onMessageReceived with Premium status changed emits to premiumStatusChangedFlow`() =
runTest {
@@ -239,6 +299,15 @@ class PushManagerTest {
}
}
@Test
fun `onMessageReceived with logout with key rotation reason emits to fullSyncFlow`() =
runTest {
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(LOGOUT_KEY_ROTATION_NOTIFICATION_MAP)
assertEquals("078966a2-93c2-4618-ae2a-0a2394c88d37", awaitItem())
}
}
@Test
fun `onMessageReceived with ciphers emits to fullSyncFlow`() = runTest {
pushManager.fullSyncFlow.test {
@@ -642,6 +711,15 @@ class PushManagerTest {
}
}
@Test
fun `onMessageReceived with logout with key rotation reason emits to fullSyncFlow`() =
runTest {
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(LOGOUT_KEY_ROTATION_NOTIFICATION_MAP)
assertEquals("078966a2-93c2-4618-ae2a-0a2394c88d37", awaitItem())
}
}
@Test
fun `onMessageReceived with sync ciphers emits to fullSyncFlow`() = runTest {
pushManager.fullSyncFlow.test {
@@ -975,6 +1053,25 @@ private val LOGOUT_KDF_NOTIFICATION_MAP = mapOf(
}""",
)
private val LOGOUT_KEY_ROTATION_NOTIFICATION_MAP = mapOf(
"contextId" to "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type" to "11",
"payload" to """{
"UserId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"Date": "2023-10-27T12:00:00.000Z",
"Reason": 1
}""",
)
private val LOGOUT_NULL_USER_ID_NOTIFICATION_MAP = mapOf(
"contextId" to "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type" to "11",
"payload" to """{
"UserId": null,
"Date": "2023-10-27T12:00:00.000Z"
}""",
)
private val POLICY_CHANGED_NOTIFICATION_MAP = mapOf(
"contextId" to "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type" to "25",
@@ -0,0 +1,361 @@
package com.x8bit.bitwarden.data.platform.manager.keyrotation
import app.cash.turbine.test
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class KeyRotationManagerTest {
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val vaultSdkSource: VaultSdkSource = mockk {
every { getKeyId(userKey = AUTHENTICATOR_SYNC_KEY) } returns STALE_KEY_ID.asSuccess()
every { getKeyId(userKey = AUTO_UNLOCK_KEY) } returns STALE_KEY_ID.asSuccess()
every { getKeyId(userKey = BIOMETRICS_KEY) } returns STALE_KEY_ID.asSuccess()
every { getKeyId(userKey = USER_ENCRYPTION_KEY) } returns CURRENT_KEY_ID.asSuccess()
coEvery { getUserEncryptionKey(userId = USER_ID) } returns USER_ENCRYPTION_KEY.asSuccess()
}
private val keyRotationManager: KeyRotationManager = KeyRotationManagerImpl(
authDiskSource = fakeAuthDiskSource,
vaultSdkSource = vaultSdkSource,
)
//region rotateAuthenticatorSyncKey
@Test
fun `rotateAuthenticatorSyncKey should do nothing when there is no stored auto-unlock key`() =
runTest {
keyRotationManager.rotateAuthenticatorSyncKey(userId = USER_ID)
fakeAuthDiskSource.assertAuthenticatorSyncKey(
userId = USER_ID,
authenticatorSyncKey = null,
)
verify(exactly = 0) { vaultSdkSource.getKeyId(userKey = any()) }
coVerify(exactly = 0) { vaultSdkSource.getUserEncryptionKey(userId = any()) }
}
@Suppress("MaxLineLength")
@Test
fun `rotateAuthenticatorSyncKey should store the user encryption key when the key IDs do not match`() =
runTest {
fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey(
userId = USER_ID,
authenticatorSyncUnlockKey = AUTHENTICATOR_SYNC_KEY,
)
keyRotationManager.rotateAuthenticatorSyncKey(userId = USER_ID)
fakeAuthDiskSource.assertAuthenticatorSyncKey(
userId = USER_ID,
authenticatorSyncKey = USER_ENCRYPTION_KEY,
)
}
@Suppress("MaxLineLength")
@Test
fun `rotateAuthenticatorSyncKey should not store the user encryption key when the key IDs match`() =
runTest {
every {
vaultSdkSource.getKeyId(userKey = AUTHENTICATOR_SYNC_KEY)
} returns CURRENT_KEY_ID.asSuccess()
fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey(
userId = USER_ID,
authenticatorSyncUnlockKey = AUTHENTICATOR_SYNC_KEY,
)
keyRotationManager.rotateAuthenticatorSyncKey(userId = USER_ID)
fakeAuthDiskSource.assertAuthenticatorSyncKey(
userId = USER_ID,
authenticatorSyncKey = AUTHENTICATOR_SYNC_KEY,
)
}
@Suppress("MaxLineLength")
@Test
fun `rotateAuthenticatorSyncKey should not store the user encryption key when getting the ID of the stored key fails`() =
runTest {
every {
vaultSdkSource.getKeyId(userKey = AUTHENTICATOR_SYNC_KEY)
} returns ERROR.asFailure()
fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey(
userId = USER_ID,
authenticatorSyncUnlockKey = AUTHENTICATOR_SYNC_KEY,
)
keyRotationManager.rotateAuthenticatorSyncKey(userId = USER_ID)
fakeAuthDiskSource.assertAuthenticatorSyncKey(
userId = USER_ID,
authenticatorSyncKey = AUTHENTICATOR_SYNC_KEY,
)
coVerify(exactly = 0) { vaultSdkSource.getUserEncryptionKey(userId = any()) }
}
@Suppress("MaxLineLength")
@Test
fun `rotateAuthenticatorSyncKey should not store the user encryption key when getting the user encryption key fails`() =
runTest {
coEvery {
vaultSdkSource.getUserEncryptionKey(userId = USER_ID)
} returns ERROR.asFailure()
fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey(
userId = USER_ID,
authenticatorSyncUnlockKey = AUTHENTICATOR_SYNC_KEY,
)
keyRotationManager.rotateAuthenticatorSyncKey(userId = USER_ID)
fakeAuthDiskSource.assertAuthenticatorSyncKey(
userId = USER_ID,
authenticatorSyncKey = AUTHENTICATOR_SYNC_KEY,
)
}
@Suppress("MaxLineLength")
@Test
fun `rotateAuthenticatorSyncKey should not store the user encryption key when getting the ID of the user encryption key fails`() =
runTest {
every {
vaultSdkSource.getKeyId(userKey = USER_ENCRYPTION_KEY)
} returns ERROR.asFailure()
fakeAuthDiskSource.storeAuthenticatorSyncUnlockKey(
userId = USER_ID,
authenticatorSyncUnlockKey = AUTHENTICATOR_SYNC_KEY,
)
keyRotationManager.rotateAuthenticatorSyncKey(userId = USER_ID)
fakeAuthDiskSource.assertAuthenticatorSyncKey(
userId = USER_ID,
authenticatorSyncKey = AUTHENTICATOR_SYNC_KEY,
)
}
//endregion rotateAuthenticatorSyncKey
//region rotateAutoUnlockKey
@Test
fun `rotateAutoUnlockKey should do nothing when there is no stored auto-unlock key`() =
runTest {
keyRotationManager.rotateAutoUnlockKey(userId = USER_ID)
fakeAuthDiskSource.assertUserAutoUnlockKey(userId = USER_ID, userAutoUnlockKey = null)
verify(exactly = 0) { vaultSdkSource.getKeyId(userKey = any()) }
coVerify(exactly = 0) { vaultSdkSource.getUserEncryptionKey(userId = any()) }
}
@Test
fun `rotateAutoUnlockKey should store the user encryption key when the key IDs do not match`() =
runTest {
fakeAuthDiskSource.storeUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = AUTO_UNLOCK_KEY,
)
keyRotationManager.rotateAutoUnlockKey(userId = USER_ID)
fakeAuthDiskSource.assertUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = USER_ENCRYPTION_KEY,
)
}
@Test
fun `rotateAutoUnlockKey should not store the user encryption key when the key IDs match`() =
runTest {
every {
vaultSdkSource.getKeyId(userKey = AUTO_UNLOCK_KEY)
} returns CURRENT_KEY_ID.asSuccess()
fakeAuthDiskSource.storeUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = AUTO_UNLOCK_KEY,
)
keyRotationManager.rotateAutoUnlockKey(userId = USER_ID)
fakeAuthDiskSource.assertUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = AUTO_UNLOCK_KEY,
)
}
@Suppress("MaxLineLength")
@Test
fun `rotateAutoUnlockKey should not store the user encryption key when getting the ID of the stored key fails`() =
runTest {
every { vaultSdkSource.getKeyId(userKey = AUTO_UNLOCK_KEY) } returns ERROR.asFailure()
fakeAuthDiskSource.storeUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = AUTO_UNLOCK_KEY,
)
keyRotationManager.rotateAutoUnlockKey(userId = USER_ID)
fakeAuthDiskSource.assertUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = AUTO_UNLOCK_KEY,
)
coVerify(exactly = 0) { vaultSdkSource.getUserEncryptionKey(userId = any()) }
}
@Suppress("MaxLineLength")
@Test
fun `rotateAutoUnlockKey should not store the user encryption key when getting the user encryption key fails`() =
runTest {
coEvery {
vaultSdkSource.getUserEncryptionKey(userId = USER_ID)
} returns ERROR.asFailure()
fakeAuthDiskSource.storeUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = AUTO_UNLOCK_KEY,
)
keyRotationManager.rotateAutoUnlockKey(userId = USER_ID)
fakeAuthDiskSource.assertUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = AUTO_UNLOCK_KEY,
)
}
@Suppress("MaxLineLength")
@Test
fun `rotateAutoUnlockKey should not store the user encryption key when getting the ID of the user encryption key fails`() =
runTest {
every {
vaultSdkSource.getKeyId(userKey = USER_ENCRYPTION_KEY)
} returns ERROR.asFailure()
fakeAuthDiskSource.storeUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = AUTO_UNLOCK_KEY,
)
keyRotationManager.rotateAutoUnlockKey(userId = USER_ID)
fakeAuthDiskSource.assertUserAutoUnlockKey(
userId = USER_ID,
userAutoUnlockKey = AUTO_UNLOCK_KEY,
)
}
//endregion rotateAutoUnlockKey
//region rotateBiometricsKey
@Test
fun `rotateBiometricsKey should emit shouldRotateBiometricKey when the key IDs do not match`() =
runTest {
keyRotationManager.shouldRotateBiometricKey.test {
expectNoEvents()
keyRotationManager.rotateBiometricsKey(
userId = USER_ID,
decryptedBiometricsUserKey = BIOMETRICS_KEY,
)
assertEquals(Unit, awaitItem())
}
}
@Test
fun `rotateBiometricsKey should not emit shouldRotateBiometricKey when the key IDs match`() =
runTest {
every { vaultSdkSource.getKeyId(userKey = BIOMETRICS_KEY) } returns
CURRENT_KEY_ID.asSuccess()
keyRotationManager.shouldRotateBiometricKey.test {
keyRotationManager.rotateBiometricsKey(
userId = USER_ID,
decryptedBiometricsUserKey = BIOMETRICS_KEY,
)
expectNoEvents()
}
}
@Suppress("MaxLineLength")
@Test
fun `rotateBiometricsKey should not emit shouldRotateBiometricKey when getting the ID of the biometrics key fails`() =
runTest {
every { vaultSdkSource.getKeyId(userKey = BIOMETRICS_KEY) } returns ERROR.asFailure()
keyRotationManager.shouldRotateBiometricKey.test {
keyRotationManager.rotateBiometricsKey(
userId = USER_ID,
decryptedBiometricsUserKey = BIOMETRICS_KEY,
)
expectNoEvents()
}
coVerify(exactly = 0) { vaultSdkSource.getUserEncryptionKey(userId = any()) }
}
@Suppress("MaxLineLength")
@Test
fun `rotateBiometricsKey should not emit shouldRotateBiometricKey when getting the user encryption key fails`() =
runTest {
coEvery {
vaultSdkSource.getUserEncryptionKey(userId = USER_ID)
} returns ERROR.asFailure()
keyRotationManager.shouldRotateBiometricKey.test {
keyRotationManager.rotateBiometricsKey(
userId = USER_ID,
decryptedBiometricsUserKey = BIOMETRICS_KEY,
)
expectNoEvents()
}
}
@Suppress("MaxLineLength")
@Test
fun `rotateBiometricsKey should not emit shouldRotateBiometricKey when getting the ID of the user encryption key fails`() =
runTest {
every {
vaultSdkSource.getKeyId(userKey = USER_ENCRYPTION_KEY)
} returns ERROR.asFailure()
keyRotationManager.shouldRotateBiometricKey.test {
keyRotationManager.rotateBiometricsKey(
userId = USER_ID,
decryptedBiometricsUserKey = BIOMETRICS_KEY,
)
expectNoEvents()
}
}
@Suppress("MaxLineLength")
@Test
fun `rotateBiometricsKey should not store the rotated key since it requires user interaction`() =
runTest {
keyRotationManager.shouldRotateBiometricKey.test {
keyRotationManager.rotateBiometricsKey(
userId = USER_ID,
decryptedBiometricsUserKey = BIOMETRICS_KEY,
)
assertEquals(Unit, awaitItem())
}
fakeAuthDiskSource.assertUserAutoUnlockKey(userId = USER_ID, userAutoUnlockKey = null)
}
//endregion rotateBiometricsKey
}
private const val USER_ID: String = "userId"
private const val AUTHENTICATOR_SYNC_KEY: String = "authenticatorSyncKey"
private const val AUTO_UNLOCK_KEY: String = "autoUnlockKey"
private const val BIOMETRICS_KEY: String = "biometricsKey"
private const val USER_ENCRYPTION_KEY: String = "userEncryptionKey"
private const val STALE_KEY_ID: String = "staleKeyId"
private const val CURRENT_KEY_ID: String = "currentKeyId"
private val ERROR: Throwable = Throwable("Fail!")
@@ -10,6 +10,7 @@ import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.MasterPasswordAuthenticationData
import com.bitwarden.core.MasterPasswordUnlockData
import com.bitwarden.core.ReinitUserCryptoRequest
import com.bitwarden.core.UpdateKdfResponse
import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
@@ -113,6 +114,7 @@ class VaultSdkSourceTest {
every { exporters() } returns clientExporters
}
private val sdkClientManager = mockk<SdkClientManager> {
every { globalClient } returns client
coEvery { getOrCreateClient(any()) } returns client
every { destroyClient(any()) } just runs
}
@@ -132,6 +134,48 @@ class VaultSdkSourceTest {
verify { sdkClientManager.destroyClient(userId = userId) }
}
@Test
fun `getKeyId should return success with the key ID returned by the SDK`() {
val userKey = "userKey"
val keyId = "keyId"
every { clientCrypto.getKeyIdForSymmetricKey(key = userKey) } returns keyId
val result = vaultSdkSource.getKeyId(userKey = userKey)
assertEquals(keyId.asSuccess(), result)
verify(exactly = 1) {
clientCrypto.getKeyIdForSymmetricKey(key = userKey)
}
}
@Test
fun `getKeyId should return a failure when the SDK returns a null key ID`() {
val userKey = "userKey"
every { clientCrypto.getKeyIdForSymmetricKey(key = userKey) } returns null
val result = vaultSdkSource.getKeyId(userKey = userKey)
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is IllegalArgumentException)
verify(exactly = 1) {
clientCrypto.getKeyIdForSymmetricKey(key = userKey)
}
}
@Test
fun `getKeyId should return a failure when the SDK throws an exception`() {
val userKey = "userKey"
val error = Throwable("Fail")
every { clientCrypto.getKeyIdForSymmetricKey(key = userKey) } throws error
val result = vaultSdkSource.getKeyId(userKey = userKey)
assertEquals(error.asFailure(), result)
verify(exactly = 1) {
clientCrypto.getKeyIdForSymmetricKey(key = userKey)
}
}
@Test
fun `getTrustDevice with trustDevice success should return success with correct data`() =
runBlocking {
@@ -502,6 +546,43 @@ class VaultSdkSourceTest {
}
}
@Test
fun `reinitializeCrypto should call SDK and return a Result with correct data`() = runTest {
val userId = "userId"
val mockReinitCryptoRequest = mockk<ReinitUserCryptoRequest>()
coEvery { clientCrypto.reinitUserCrypto(req = mockReinitCryptoRequest) } just runs
val result = vaultSdkSource.reinitializeCrypto(
userId = userId,
request = mockReinitCryptoRequest,
)
assertEquals(Unit.asSuccess(), result)
coVerify(exactly = 1) {
sdkClientManager.getOrCreateClient(userId = userId)
clientCrypto.reinitUserCrypto(req = mockReinitCryptoRequest)
}
}
@Test
fun `reinitializeCrypto should return a Failure when the SDK throws an exception`() = runTest {
val userId = "userId"
val mockReinitCryptoRequest = mockk<ReinitUserCryptoRequest>()
val error = BitwardenException.Crypto(CryptoException.MissingKey("mockException"))
coEvery { clientCrypto.reinitUserCrypto(req = mockReinitCryptoRequest) } throws error
val result = vaultSdkSource.reinitializeCrypto(
userId = userId,
request = mockReinitCryptoRequest,
)
assertEquals(error.asFailure(), result)
coVerify(exactly = 1) {
sdkClientManager.getOrCreateClient(userId = userId)
clientCrypto.reinitUserCrypto(req = mockReinitCryptoRequest)
}
}
@Test
fun `initializeUserCrypto with sdk success should return InitializeCryptoResult Success`() =
runBlocking {
@@ -33,6 +33,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.updateForcePasswordReset
import com.x8bit.bitwarden.data.platform.manager.keyrotation.KeyRotationManager
import com.x8bit.bitwarden.data.platform.manager.policy.PasswordPolicyManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
@@ -124,6 +125,10 @@ class VaultLockManagerTest {
private val passwordPolicyManager: PasswordPolicyManager = mockk {
every { validatePasswordAgainstPolicies(password = any(), isCreation = any()) } returns true
}
private val keyRotationManager: KeyRotationManager = mockk {
coEvery { rotateAutoUnlockKey(userId = any()) } just runs
coEvery { rotateAuthenticatorSyncKey(userId = any()) } just runs
}
private val vaultLockManager: VaultLockManager = VaultLockManagerImpl(
context = context,
@@ -140,6 +145,7 @@ class VaultLockManagerTest {
kdfManager = kdfManager,
pinProtectedUserKeyManager = pinProtectedUserKeyManager,
passwordPolicyManager = passwordPolicyManager,
keyRotationManager = keyRotationManager,
)
@Test
@@ -1007,6 +1013,8 @@ class VaultLockManagerTest {
)
trustedDeviceManager.trustThisDeviceIfNecessary(userId = USER_ID)
kdfManager.updateKdfToMinimumsIfNeeded(masterPassword)
keyRotationManager.rotateAutoUnlockKey(userId = USER_ID)
keyRotationManager.rotateAuthenticatorSyncKey(userId = USER_ID)
}
}
@@ -1184,6 +1192,8 @@ class VaultLockManagerTest {
)
vaultSdkSource.getUserEncryptionKey(userId = USER_ID)
trustedDeviceManager.trustThisDeviceIfNecessary(userId = USER_ID)
keyRotationManager.rotateAutoUnlockKey(userId = USER_ID)
keyRotationManager.rotateAuthenticatorSyncKey(userId = USER_ID)
}
}
@@ -1755,6 +1765,8 @@ class VaultLockManagerTest {
)
trustedDeviceManager.trustThisDeviceIfNecessary(userId = USER_ID)
pinProtectedUserKeyManager.migratePinProtectedUserKeyIfNeeded(userId = USER_ID)
keyRotationManager.rotateAutoUnlockKey(userId = USER_ID)
keyRotationManager.rotateAuthenticatorSyncKey(userId = USER_ID)
}
}
@@ -1854,6 +1866,8 @@ class VaultLockManagerTest {
kdf = MOCK_MASTER_PASSWORD_UNLOCK_DATA.kdf,
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
keyRotationManager.rotateAutoUnlockKey(userId = USER_ID)
keyRotationManager.rotateAuthenticatorSyncKey(userId = USER_ID)
}
}
@@ -4,6 +4,7 @@ import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.ReinitUserCryptoRequest
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.core.data.repository.model.DataState
@@ -24,6 +25,7 @@ import com.bitwarden.network.model.createMockPolicy
import com.bitwarden.network.model.createMockProfile
import com.bitwarden.network.model.createMockSend
import com.bitwarden.network.model.createMockSyncResponse
import com.bitwarden.network.model.createMockUserDecryption
import com.bitwarden.network.model.createMockV2UpgradeToken
import com.bitwarden.network.service.SyncService
import com.bitwarden.send.SendView
@@ -63,6 +65,7 @@ import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList
import com.x8bit.bitwarden.data.vault.repository.util.toV2UpgradeToken
import io.mockk.awaits
import io.mockk.coEvery
import io.mockk.coVerify
@@ -115,6 +118,9 @@ class VaultSyncManagerTest {
}
private val vaultSdkSource: VaultSdkSource = mockk {
every { clearCrypto(userId = any()) } just runs
coEvery {
reinitializeCrypto(userId = any(), request = any())
} returns Unit.asSuccess()
}
private val mutableVaultStateFlow = MutableStateFlow<List<VaultUnlockData>>(emptyList())
private val mutableUnlockedUserIdsStateFlow = MutableStateFlow<Set<String>>(emptySet())
@@ -791,6 +797,167 @@ class VaultSyncManagerTest {
verify(exactly = 1) { fillAssistManager.syncIfNecessary() }
}
@Suppress("MaxLineLength")
@Test
fun `sync with syncService Success and both an account cryptographic state and a V2 upgrade token and an unlocked vault should reinitialize the crypto`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val mockSyncResponse = createMockSyncResponse(number = 1)
coEvery { syncService.sync() } returns mockSyncResponse.asSuccess()
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(number = 1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
coEvery {
vaultDiskSource.replaceVaultData(userId = userId, vault = mockSyncResponse)
} just runs
every {
settingsDiskSource.storeLastSyncTime(
userId = userId,
lastSyncTime = clock.instant(),
)
} just runs
setVaultToUnlocked(userId = userId)
vaultSyncManager.sync()
coVerify(exactly = 1) {
vaultSdkSource.reinitializeCrypto(
userId = userId,
request = ReinitUserCryptoRequest(
accountCryptographicState = createMockWrappedAccountCryptographicState(
number = 1,
),
upgradeToken = createMockV2UpgradeToken(number = 1).toV2UpgradeToken(),
),
)
}
}
@Test
fun `sync with syncService Success and a locked vault should not reinitialize the crypto`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val mockSyncResponse = createMockSyncResponse(number = 1)
coEvery { syncService.sync() } returns mockSyncResponse.asSuccess()
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(number = 1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
coEvery {
vaultDiskSource.replaceVaultData(userId = userId, vault = mockSyncResponse)
} just runs
every {
settingsDiskSource.storeLastSyncTime(
userId = userId,
lastSyncTime = clock.instant(),
)
} just runs
vaultSyncManager.sync()
fakeAuthDiskSource.assertAccountCryptographicState(
userId = userId,
accountCryptographicState = createMockWrappedAccountCryptographicState(number = 1),
)
fakeAuthDiskSource.assertV2UpgradeToken(
userId = userId,
v2UpgradeToken = createMockV2UpgradeToken(number = 1),
)
coVerify(exactly = 0) {
vaultSdkSource.reinitializeCrypto(userId = any(), request = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `sync with syncService Success and a null V2 upgrade token should not reinitialize the crypto`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val mockSyncResponse = createMockSyncResponse(
number = 1,
userDecryption = createMockUserDecryption(number = 1, v2UpgradeToken = null),
)
coEvery { syncService.sync() } returns mockSyncResponse.asSuccess()
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(number = 1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
coEvery {
vaultDiskSource.replaceVaultData(userId = userId, vault = mockSyncResponse)
} just runs
every {
settingsDiskSource.storeLastSyncTime(
userId = userId,
lastSyncTime = clock.instant(),
)
} just runs
setVaultToUnlocked(userId = userId)
vaultSyncManager.sync()
fakeAuthDiskSource.assertV2UpgradeToken(userId = userId, v2UpgradeToken = null)
coVerify(exactly = 0) {
vaultSdkSource.reinitializeCrypto(userId = any(), request = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `sync with syncService Success and a null account cryptographic state should not reinitialize the crypto`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val mockSyncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(number = 1, privateKey = null, accountKeys = null),
)
coEvery { syncService.sync() } returns mockSyncResponse.asSuccess()
coEvery {
vaultSdkSource.initializeOrganizationCrypto(
userId = userId,
request = InitOrgCryptoRequest(
organizationKeys = createMockOrganizationKeys(number = 1),
),
)
} returns InitializeCryptoResult.Success.asSuccess()
coEvery {
vaultDiskSource.replaceVaultData(userId = userId, vault = mockSyncResponse)
} just runs
every {
settingsDiskSource.storeLastSyncTime(
userId = userId,
lastSyncTime = clock.instant(),
)
} just runs
setVaultToUnlocked(userId = userId)
vaultSyncManager.sync()
fakeAuthDiskSource.assertAccountCryptographicState(
userId = userId,
accountCryptographicState = null,
)
coVerify(exactly = 0) {
vaultSdkSource.reinitializeCrypto(userId = any(), request = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `sync with syncService Success with a different security stamp should logout and return early`() =
@@ -37,6 +37,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.createMockWrappedAccountCr
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.keyrotation.KeyRotationManager
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockAccount
@@ -123,6 +124,11 @@ class VaultRepositoryTest {
private val featureFlagManager: FeatureFlagManager = mockk {
every { getFeatureFlag(FlagKey.SdkPinUnlock) } returns true
}
private val keyRotationManager: KeyRotationManager = mockk {
coEvery {
rotateBiometricsKey(userId = any(), decryptedBiometricsUserKey = any())
} just runs
}
private val vaultRepository: VaultRepository = VaultRepositoryImpl(
vaultDiskSource = vaultDiskSource,
@@ -137,6 +143,7 @@ class VaultRepositoryTest {
vaultSyncManager = vaultSyncManager,
credentialExchangeImportManager = credentialExchangeImportManager,
pinProtectedUserKeyManager = pinProtectedUserKeyManager,
keyRotationManager = keyRotationManager,
featureFlagManager = featureFlagManager,
)
@@ -261,6 +268,7 @@ class VaultRepositoryTest {
val biometricsKey = "asdf1234"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val encryptedBytes = byteArrayOf(1, 1)
val decryptedUserKey = encryptedBytes.toString(Charsets.ISO_8859_1)
val initVector = byteArrayOf(2, 2)
val cipher = mockk<Cipher> {
every { doFinal(any()) } returns encryptedBytes
@@ -272,7 +280,7 @@ class VaultRepositoryTest {
email = "email",
kdf = MOCK_PROFILE.toSdkParams(),
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = encryptedBytes.toString(Charsets.ISO_8859_1),
decryptedUserKey = decryptedUserKey,
),
organizationKeys = null,
)
@@ -296,10 +304,14 @@ class VaultRepositoryTest {
email = "email",
kdf = MOCK_PROFILE.toSdkParams(),
initUserCryptoMethod = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = encryptedBytes.toString(Charsets.ISO_8859_1),
decryptedUserKey = decryptedUserKey,
),
organizationKeys = null,
)
keyRotationManager.rotateBiometricsKey(
userId = userId,
decryptedBiometricsUserKey = decryptedUserKey,
)
}
}
@@ -354,6 +366,10 @@ class VaultRepositoryTest {
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = userId,
)
keyRotationManager.rotateBiometricsKey(
userId = userId,
decryptedBiometricsUserKey = biometricsKey,
)
}
fakeAuthDiskSource.apply {
assertBiometricsKey(
@@ -0,0 +1,268 @@
package com.x8bit.bitwarden.ui.platform.feature.biometrics
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
import javax.crypto.Cipher
class UpdateBiometricsScreenTest : BitwardenComposeTest() {
private var onDismissCalled = false
private val captureBiometricsSuccess = slot<(cipher: Cipher?) -> Unit>()
private val captureBiometricsCancel = slot<() -> Unit>()
private val captureBiometricsLockOut = slot<() -> Unit>()
private val captureBiometricsError = slot<() -> Unit>()
private val biometricsManager: BiometricsManager = mockk {
every {
promptBiometrics(
onSuccess = capture(captureBiometricsSuccess),
onCancel = capture(captureBiometricsCancel),
onLockOut = capture(captureBiometricsLockOut),
onError = capture(captureBiometricsError),
cipher = CIPHER,
)
} just runs
}
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<UpdateBiometricsEvent>()
private val viewModel = mockk<UpdateBiometricsViewModel> {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
every { trySendAction(action = any()) } just runs
}
@Before
fun setup() {
setContent(biometricsManager = biometricsManager) {
UpdateBiometricsScreen(
onDismiss = { onDismissCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `content should be displayed`() {
composeTestRule
.onNodeWithText(text = "Keep unlocking with biometrics")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(
text = "We’ve made a security update. " +
"Confirm your biometrics again to keep using them to unlock Bitwarden.",
)
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Confirm")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Turn off")
.performScrollTo()
.assertIsDisplayed()
}
@Test
fun `on Confirm biometrics click should send UpdateBiometricsClick action`() {
composeTestRule
.onNodeWithText(text = "Confirm")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(UpdateBiometricsAction.ConfirmBiometricsClick)
}
}
@Test
fun `on Turn off click should send TurnOffBiometricsClick action`() {
composeTestRule
.onNodeWithText(text = "Turn off")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(UpdateBiometricsAction.TurnOffBiometricsClick)
}
}
@Test
fun `on system back should send TurnOffBiometricsClick action`() {
backDispatcher?.onBackPressed()
verify(exactly = 1) {
viewModel.trySendAction(UpdateBiometricsAction.TurnOffBiometricsClick)
}
}
@Test
fun `on NavigateBack event should invoke onDismiss`() {
mutableEventFlow.tryEmit(UpdateBiometricsEvent.NavigateBack)
assertTrue(onDismissCalled)
}
@Suppress("MaxLineLength")
@Test
fun `on ShowBiometricsPrompt event should prompt for biometrics and send BiometricCipherReceived on success`() {
mutableEventFlow.tryEmit(UpdateBiometricsEvent.ShowBiometricsPrompt(cipher = CIPHER))
captureBiometricsSuccess.captured(CIPHER)
verify(exactly = 1) {
biometricsManager.promptBiometrics(
onSuccess = any(),
onCancel = any(),
onLockOut = any(),
onError = any(),
cipher = CIPHER,
)
viewModel.trySendAction(UpdateBiometricsAction.BiometricCipherReceived(cipher = CIPHER))
}
}
@Test
fun `on ShowBiometricsPrompt event should not send any action on cancel, lock out, or error`() {
mutableEventFlow.tryEmit(UpdateBiometricsEvent.ShowBiometricsPrompt(cipher = CIPHER))
captureBiometricsCancel.captured()
captureBiometricsLockOut.captured()
captureBiometricsError.captured()
verify(exactly = 0) {
viewModel.trySendAction(action = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `DisableBiometricsAndClose dialog should be displayed according to state and send ConfirmTurnOffClick on confirm`() {
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(dialogState = UpdateBiometricsState.DialogState.DisableBiometricsAndClose)
}
composeTestRule
.onAllNodesWithText(text = "Turn off biometrics?")
.filterToOne(matcher = hasAnyAncestor(matcher = isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "Unlocking will require your master password. " +
"You can turn biometrics back on in Settings.",
)
.filterToOne(matcher = hasAnyAncestor(matcher = isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Turn off")
.filterToOne(matcher = hasAnyAncestor(matcher = isDialog()))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(UpdateBiometricsAction.ConfirmTurnOffClick)
}
}
@Test
fun `DisableBiometricsAndClose dialog cancel click should send DismissDialog action`() {
mutableStateFlow.update {
it.copy(dialogState = UpdateBiometricsState.DialogState.DisableBiometricsAndClose)
}
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(matcher = hasAnyAncestor(matcher = isDialog()))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(UpdateBiometricsAction.DismissDialog)
}
}
@Test
fun `Loading dialog should be displayed according to state`() {
val message = "message"
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(
dialogState = UpdateBiometricsState.DialogState.Loading(message = message.asText()),
)
}
composeTestRule
.onAllNodesWithText(text = message)
.filterToOne(matcher = hasAnyAncestor(matcher = isDialog()))
.assertIsDisplayed()
mutableStateFlow.update { it.copy(dialogState = null) }
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `Error dialog should be displayed according to state and send DismissDialog action on click`() {
val title = "title"
val message = "message"
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(
dialogState = UpdateBiometricsState.DialogState.Error(
title = title.asText(),
message = message.asText(),
),
)
}
composeTestRule
.onAllNodesWithText(text = title)
.filterToOne(matcher = hasAnyAncestor(matcher = isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = message)
.filterToOne(matcher = hasAnyAncestor(matcher = isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Okay")
.filterToOne(matcher = hasAnyAncestor(matcher = isDialog()))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(UpdateBiometricsAction.DismissDialog)
}
mutableStateFlow.update { it.copy(dialogState = null) }
composeTestRule.assertNoDialogExists()
}
}
private val CIPHER: Cipher = mockk()
private val DEFAULT_STATE: UpdateBiometricsState = UpdateBiometricsState(
dialogState = null,
)
@@ -0,0 +1,275 @@
package com.x8bit.bitwarden.ui.platform.feature.biometrics
import app.cash.turbine.test
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import javax.crypto.Cipher
class UpdateBiometricsViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val authRepository: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
every { activeUserId } returns DEFAULT_USER_ID
every { clearBiometrics(userId = DEFAULT_USER_ID) } just runs
every { createCipherOrNull(userId = DEFAULT_USER_ID) } returns CIPHER
}
private val settingsRepository: SettingsRepository = mockk {
every { vaultTimeoutAction = any() } just runs
}
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `TurnOffBiometricsClick should display the disable biometrics dialog`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(UpdateBiometricsAction.TurnOffBiometricsClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = UpdateBiometricsState.DialogState.DisableBiometricsAndClose,
),
awaitItem(),
)
}
}
@Test
fun `DismissDialog should clear the dialog state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(UpdateBiometricsAction.TurnOffBiometricsClick)
assertEquals(
DEFAULT_STATE.copy(
dialogState = UpdateBiometricsState.DialogState.DisableBiometricsAndClose,
),
awaitItem(),
)
viewModel.trySendAction(UpdateBiometricsAction.DismissDialog)
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `ConfirmTurnOffClick should clear biometrics and emit NavigateBack when the user has a manual unlock mechanism`() =
runTest {
val viewModel = createViewModel()
viewModel.trySendAction(UpdateBiometricsAction.TurnOffBiometricsClick)
viewModel.eventFlow.test {
viewModel.trySendAction(UpdateBiometricsAction.ConfirmTurnOffClick)
assertEquals(UpdateBiometricsEvent.NavigateBack, awaitItem())
}
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
verify(exactly = 1) {
authRepository.clearBiometrics(userId = DEFAULT_USER_ID)
}
verify(exactly = 0) {
settingsRepository.vaultTimeoutAction = VaultTimeoutAction.LOGOUT
}
}
@Suppress("MaxLineLength")
@Test
fun `ConfirmTurnOffClick should set the vault timeout action to LOGOUT when the user has no manual unlock mechanism`() =
runTest {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_USER_ACCOUNT.copy(hasMasterPassword = false)),
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(UpdateBiometricsAction.ConfirmTurnOffClick)
assertEquals(UpdateBiometricsEvent.NavigateBack, awaitItem())
}
verify(exactly = 1) {
authRepository.clearBiometrics(userId = DEFAULT_USER_ID)
settingsRepository.vaultTimeoutAction = VaultTimeoutAction.LOGOUT
}
}
@Suppress("MaxLineLength")
@Test
fun `ConfirmTurnOffClick should emit NavigateBack without clearing biometrics when there is no user state`() =
runTest {
mutableUserStateFlow.value = null
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(UpdateBiometricsAction.ConfirmTurnOffClick)
assertEquals(UpdateBiometricsEvent.NavigateBack, awaitItem())
}
verify(exactly = 0) {
authRepository.clearBiometrics(userId = any())
settingsRepository.vaultTimeoutAction = any()
}
}
@Suppress("MaxLineLength")
@Test
fun `UpdateBiometricsClick should clear biometrics and emit ShowBiometricsPrompt with the new cipher`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(UpdateBiometricsAction.ConfirmBiometricsClick)
assertEquals(
UpdateBiometricsEvent.ShowBiometricsPrompt(cipher = CIPHER),
awaitItem(),
)
}
verify(exactly = 1) {
authRepository.clearBiometrics(userId = DEFAULT_USER_ID)
authRepository.createCipherOrNull(userId = DEFAULT_USER_ID)
}
}
@Test
fun `UpdateBiometricsClick should display an error dialog when there is no active user`() =
runTest {
every { authRepository.activeUserId } returns null
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(UpdateBiometricsAction.ConfirmBiometricsClick)
assertEquals(DEFAULT_STATE.copy(dialogState = ERROR_DIALOG_STATE), awaitItem())
}
verify(exactly = 0) {
authRepository.clearBiometrics(userId = any())
authRepository.createCipherOrNull(userId = any())
}
}
@Test
fun `UpdateBiometricsClick should display an error dialog when the cipher is null`() = runTest {
every { authRepository.createCipherOrNull(userId = DEFAULT_USER_ID) } returns null
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(UpdateBiometricsAction.ConfirmBiometricsClick)
assertEquals(DEFAULT_STATE.copy(dialogState = ERROR_DIALOG_STATE), awaitItem())
}
verify(exactly = 1) {
authRepository.clearBiometrics(userId = DEFAULT_USER_ID)
}
}
@Suppress("MaxLineLength")
@Test
fun `BiometricCipherReceived should display a loading dialog and a success dialog when setting up the key succeeds`() =
runTest {
coEvery {
settingsRepository.setupBiometricsKey(cipher = CIPHER)
} returns BiometricsKeyResult.Success
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
viewModel.trySendAction(
UpdateBiometricsAction.BiometricCipherReceived(cipher = CIPHER),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = UpdateBiometricsState.DialogState.Loading(
message = BitwardenString.saving.asText(),
),
),
stateFlow.awaitItem(),
)
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
assertEquals(UpdateBiometricsEvent.NavigateBack, eventFlow.awaitItem())
}
coVerify(exactly = 1) {
settingsRepository.setupBiometricsKey(cipher = CIPHER)
}
}
@Test
fun `BiometricCipherReceived should display an error dialog when setting up the key fails`() =
runTest {
coEvery {
settingsRepository.setupBiometricsKey(cipher = CIPHER)
} returns BiometricsKeyResult.Error(error = Throwable("Fail!"))
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(
UpdateBiometricsAction.BiometricCipherReceived(cipher = CIPHER),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = UpdateBiometricsState.DialogState.Loading(
message = BitwardenString.saving.asText(),
),
),
awaitItem(),
)
assertEquals(DEFAULT_STATE.copy(dialogState = ERROR_DIALOG_STATE), awaitItem())
}
coVerify(exactly = 1) {
settingsRepository.setupBiometricsKey(cipher = CIPHER)
}
}
private fun createViewModel(): UpdateBiometricsViewModel = UpdateBiometricsViewModel(
authRepository = authRepository,
settingsRepository = settingsRepository,
)
}
private const val DEFAULT_USER_ID: String = "activeUserId"
private val CIPHER: Cipher = mockk()
private val DEFAULT_STATE: UpdateBiometricsState = UpdateBiometricsState(dialogState = null)
private val ERROR_DIALOG_STATE: UpdateBiometricsState.DialogState.Error =
UpdateBiometricsState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
)
private val DEFAULT_USER_ACCOUNT: UserState.Account = UserState.Account(
userId = DEFAULT_USER_ID,
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
environment = Environment.Prod.Us,
isPremium = true,
isPremiumFromSelf = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(),
isExportable = true,
creationDate = null,
)
private val DEFAULT_USER_STATE: UserState = UserState(
activeUserId = DEFAULT_USER_ID,
accounts = listOf(DEFAULT_USER_ACCOUNT),
)
@@ -4,6 +4,7 @@ import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.base.createMockNavHostController
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.AccessibilityDisclosureRoute
import com.x8bit.bitwarden.ui.platform.feature.biometrics.UpdateBiometricsRoute
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.CookieAcquisitionRoute
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.LocalNetworkAccessRoute
import io.mockk.every
@@ -32,6 +33,16 @@ class OverlayNavScreenTest : BitwardenComposeTest() {
}
}
@Test
fun `on NavigateToUpdateBiometrics should navigate to the update biometrics screen`() {
mutableEventFlow.tryEmit(OverlayNavEvent.NavigateToUpdateBiometrics)
composeTestRule.runOnIdle {
verify(exactly = 1) {
mockNavHostController.navigate(route = UpdateBiometricsRoute, builder = any())
}
}
}
@Test
fun `on NavigateToCookieAcquisition should navigate to the cookie acquisition screen`() {
mutableEventFlow.tryEmit(OverlayNavEvent.NavigateToCookieAcquisition)
@@ -1,11 +1,13 @@
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
import app.cash.turbine.test
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
@@ -26,6 +28,10 @@ class OverlayNavViewModelTest : BaseViewModelTest() {
private val cookieAcquisitionRequestManager: CookieAcquisitionRequestManager = mockk {
every { cookieAcquisitionRequestFlow } returns mutableCookieAcquisitionFlow
}
private val mutableShouldRotateBiometricKeyFlow = bufferedMutableSharedFlow<Unit>()
private val vaultRepository: VaultRepository = mockk {
every { shouldRotateBiometricKey } returns mutableShouldRotateBiometricKeyFlow
}
@Suppress("MaxLineLength")
@Test
@@ -63,9 +69,21 @@ class OverlayNavViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `when the biometric key should be rotated should emit NavigateToUpdateBiometrics`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
expectNoEvents()
mutableShouldRotateBiometricKeyFlow.tryEmit(Unit)
assertEquals(OverlayNavEvent.NavigateToUpdateBiometrics, awaitItem())
}
}
private fun createViewModel(): OverlayNavViewModel = OverlayNavViewModel(
cookieAcquisitionRequestManager = cookieAcquisitionRequestManager,
networkPermissionManager = networkPermissionManager,
vaultRepository = vaultRepository,
settingsRepository = settingsRepository,
)
}
+5
View File
@@ -1447,4 +1447,9 @@ Do you want to switch to this account?</string>
<string name="use_this_passphrase">Use this passphrase</string>
<string name="use_this_password">Use this password</string>
<string name="use_this_username">Use this username</string>
<string name="keep_unlocking_with_biometrics">Keep unlocking with biometrics</string>
<string name="weve_made_a_security_update">We’ve made a security update. Confirm your biometrics again to keep using them to unlock Bitwarden.</string>
<string name="turn_off">Turn off</string>
<string name="turn_off_biometric_question">Turn off biometrics?</string>
<string name="unlocking_will_require_your_master_password">Unlocking will require your master password. You can turn biometrics back on in Settings.</string>
</resources>