From 1f0c69aa5b61846c982dc87dd1faa8a0acc271df Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 24 Sep 2026 15:03:12 +0000 Subject: [PATCH] PM-37902: feat: handle key rotations (#7420) --- .../data/platform/manager/PushManagerImpl.kt | 24 +- .../manager/di/PlatformManagerModule.kt | 13 + .../manager/keyrotation/KeyRotationManager.kt | 28 ++ .../keyrotation/KeyRotationManagerImpl.kt | 75 ++++ .../manager/model/NotificationPayload.kt | 2 +- .../model/PushNotificationLogOutReason.kt | 3 + .../AuthenticatorBridgeRepositoryImpl.kt | 5 +- .../vault/datasource/sdk/VaultSdkSource.kt | 15 + .../datasource/sdk/VaultSdkSourceImpl.kt | 13 + .../vault/manager/VaultLockManagerImpl.kt | 4 + .../vault/manager/VaultSyncManagerImpl.kt | 33 +- .../vault/manager/di/VaultManagerModule.kt | 3 + .../data/vault/repository/VaultRepository.kt | 2 + .../vault/repository/VaultRepositoryImpl.kt | 7 + .../repository/di/VaultRepositoryModule.kt | 3 + .../biometrics/UpdateBiometricsNavigation.kt | 40 ++ .../biometrics/UpdateBiometricsScreen.kt | 208 ++++++++++ .../biometrics/UpdateBiometricsViewModel.kt | 223 +++++++++++ .../feature/overlaynav/OverlayNavScreen.kt | 10 + .../feature/overlaynav/OverlayNavViewModel.kt | 24 ++ .../disk/util/FakeAuthDiskSource.kt | 7 + .../data/platform/manager/PushManagerTest.kt | 97 +++++ .../keyrotation/KeyRotationManagerTest.kt | 361 ++++++++++++++++++ .../datasource/sdk/VaultSdkSourceTest.kt | 81 ++++ .../vault/manager/VaultLockManagerTest.kt | 14 + .../vault/manager/VaultSyncManagerTest.kt | 167 ++++++++ .../vault/repository/VaultRepositoryTest.kt | 20 +- .../biometrics/UpdateBiometricsScreenTest.kt | 268 +++++++++++++ .../UpdateBiometricsViewModelTest.kt | 275 +++++++++++++ .../overlaynav/OverlayNavScreenTest.kt | 11 + .../overlaynav/OverlayNavViewModelTest.kt | 18 + ui/src/main/res/values/strings.xml | 5 + 32 files changed, 2039 insertions(+), 20 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManager.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManagerImpl.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsNavigation.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsScreen.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsViewModel.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManagerTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsScreenTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsViewModelTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt index 3401cc365d..feadfb2b97 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt @@ -166,14 +166,28 @@ class PushManagerImpl @Inject constructor( .decodeFromString( 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), + ) + } + } } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt index 139a0393e5..dd746b1b7b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt @@ -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, + ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManager.kt new file mode 100644 index 0000000000..b8a767fb19 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManager.kt @@ -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 + + /** + * 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) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManagerImpl.kt new file mode 100644 index 0000000000..c8dfe001d0 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManagerImpl.kt @@ -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() + override val shouldRotateBiometricKey: Flow = 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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt index 509df2d05d..6cd45951e0 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt @@ -58,7 +58,7 @@ sealed class NotificationPayload { val date: Instant?, @JsonNames("Reason", "reason") - val pushNotificationLogOutReason: PushNotificationLogOutReason?, + val logOutReason: PushNotificationLogOutReason?, ) : NotificationPayload() /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/PushNotificationLogOutReason.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/PushNotificationLogOutReason.kt index d0dfb505b7..101b60734a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/PushNotificationLogOutReason.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/PushNotificationLogOutReason.kt @@ -12,6 +12,9 @@ import kotlinx.serialization.Serializable enum class PushNotificationLogOutReason { @SerialName("0") KDF_CHANGE, + + @SerialName("1") + KEY_ROTATION, } @Keep diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryImpl.kt index 0b45b35f47..8692185cf4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/AuthenticatorBridgeRepositoryImpl.kt @@ -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 -> diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index beb61a8cf9..30017d7a83 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -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 + /** * Gets the data to authenticate with trusted device encryption. * @@ -140,6 +146,15 @@ interface VaultSdkSource { */ suspend fun getUserFingerprint(userId: String): Result + /** + * 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 + /** * Attempts to initialize cryptography functionality for an individual user with the given * [userId] for the Bitwarden SDK with a given [InitUserCryptoRequest]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index 335b94aab0..d3435d8552 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -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 = 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 = runCatchingWithLogs { @@ -186,6 +192,13 @@ class VaultSdkSourceImpl( .userFingerprint(fingerprintMaterial = userId) } + override suspend fun reinitializeCrypto( + userId: String, + request: ReinitUserCryptoRequest, + ): Result = runCatchingWithLogs { + getClient(userId = userId).crypto().reinitUserCrypto(req = request) + } + override suspend fun initializeCrypto( userId: String, request: InitUserCryptoRequest, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt index 577fd84979..b51d69e762 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt @@ -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) } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt index b924b3dfdc..e88a269b18 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerImpl.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index 29b9e1c796..21addef91d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index ee813706e6..e119ff8e81 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -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 { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 89d52c3548..9ab8a0fdf8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -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, + ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt index 464d37cf9e..1c912185ab 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt @@ -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, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsNavigation.kt new file mode 100644 index 0000000000..08732489f5 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsNavigation.kt @@ -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 { + 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 + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsScreen.kt new file mode 100644 index 0000000000..17ed968f75 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsScreen.kt @@ -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()) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsViewModel.kt new file mode 100644 index 0000000000..0992882cd3 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsViewModel.kt @@ -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( + 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() + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavScreen.kt index 01dd808067..78a193132f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavScreen.kt @@ -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() } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavViewModel.kt index 41ead957f5..17e641b8f4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavViewModel.kt @@ -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(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() } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 627a8086f1..950d77584f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -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]. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt index 1b5e176759..7613161924 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt @@ -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", diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManagerTest.kt new file mode 100644 index 0000000000..ac3ef21e45 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/keyrotation/KeyRotationManagerTest.kt @@ -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!") diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt index 802911dec3..416e9bba7f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt @@ -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 { + 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() + 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() + 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 { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt index 0ad4c6e936..198d098233 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt @@ -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) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt index 4cb4eb135e..0c1387f39f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt @@ -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>(emptyList()) private val mutableUnlockedUserIdsStateFlow = MutableStateFlow>(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`() = diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 023259fdef..fb8fb54173 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -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 { 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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsScreenTest.kt new file mode 100644 index 0000000000..e0f94df163 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsScreenTest.kt @@ -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() + private val viewModel = mockk { + 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, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsViewModelTest.kt new file mode 100644 index 0000000000..f8baf61d40 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/biometrics/UpdateBiometricsViewModelTest.kt @@ -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(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), +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavScreenTest.kt index cef81f8701..e11ebbc961 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavScreenTest.kt @@ -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) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavViewModelTest.kt index b0849576b4..80e7e0477e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/overlaynav/OverlayNavViewModelTest.kt @@ -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() + 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, ) } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 94d7682b02..c644c1050d 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1447,4 +1447,9 @@ Do you want to switch to this account? Use this passphrase Use this password Use this username + Keep unlocking with biometrics + We’ve made a security update. Confirm your biometrics again to keep using them to unlock Bitwarden. + Turn off + Turn off biometrics? + Unlocking will require your master password. You can turn biometrics back on in Settings.