mirror of
https://github.com/bitwarden/android.git
synced 2026-10-22 02:53:19 -05:00
PM-37902: feat: handle key rotations (#7420)
This commit is contained in:
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -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,
|
||||
)
|
||||
}
|
||||
+28
@@ -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)
|
||||
}
|
||||
+75
@@ -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()
|
||||
}
|
||||
+1
-1
@@ -58,7 +58,7 @@ sealed class NotificationPayload {
|
||||
val date: Instant?,
|
||||
|
||||
@JsonNames("Reason", "reason")
|
||||
val pushNotificationLogOutReason: PushNotificationLogOutReason?,
|
||||
val logOutReason: PushNotificationLogOutReason?,
|
||||
) : NotificationPayload()
|
||||
|
||||
/**
|
||||
|
||||
+3
@@ -12,6 +12,9 @@ import kotlinx.serialization.Serializable
|
||||
enum class PushNotificationLogOutReason {
|
||||
@SerialName("0")
|
||||
KDF_CHANGE,
|
||||
|
||||
@SerialName("1")
|
||||
KEY_ROTATION,
|
||||
}
|
||||
|
||||
@Keep
|
||||
|
||||
+4
-1
@@ -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].
|
||||
|
||||
+13
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-11
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+208
@@ -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())
|
||||
}
|
||||
}
|
||||
+223
@@ -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()
|
||||
}
|
||||
}
|
||||
+10
@@ -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()
|
||||
}
|
||||
|
||||
+24
@@ -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()
|
||||
}
|
||||
}
|
||||
+7
@@ -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",
|
||||
|
||||
+361
@@ -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!")
|
||||
+81
@@ -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`() =
|
||||
|
||||
+18
-2
@@ -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(
|
||||
|
||||
+268
@@ -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,
|
||||
)
|
||||
+275
@@ -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),
|
||||
)
|
||||
+11
@@ -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)
|
||||
|
||||
+18
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user