From d98ff6478fe4ff7efd0e8fe2f00978fb72947b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Thu, 9 Oct 2025 08:49:22 +0100 Subject: [PATCH] [PM-23278] Upgrade user KDF settings to minimums (#5955) Co-authored-by: David Perez --- .../auth/datasource/sdk/util/KdfExtensions.kt | 21 + .../bitwarden/data/auth/manager/KdfManager.kt | 19 + .../data/auth/manager/KdfManagerImpl.kt | 101 +++++ .../data/auth/manager/di/AuthManagerModule.kt | 17 + .../data/auth/repository/AuthRepository.kt | 7 +- .../auth/repository/AuthRepositoryImpl.kt | 15 + .../repository/di/AuthRepositoryModule.kt | 3 + .../model/UpdateKdfMinimumsResult.kt | 25 ++ .../util/UserStateJsonExtensions.kt | 26 ++ .../vault/datasource/sdk/VaultSdkSource.kt | 10 + .../datasource/sdk/VaultSdkSourceImpl.kt | 11 + .../vault/manager/VaultLockManagerImpl.kt | 23 +- .../vault/manager/di/VaultManagerModule.kt | 3 + .../dialog/BitwardenMasterPasswordDialog.kt | 19 +- .../ui/vault/feature/vault/VaultScreen.kt | 13 + .../ui/vault/feature/vault/VaultViewModel.kt | 135 +++++- .../feature/vault/handlers/VaultHandlers.kt | 4 + .../data/auth/manager/KdfManagerTest.kt | 422 ++++++++++++++++++ .../auth/repository/AuthRepositoryTest.kt | 43 +- .../util/UserStateJsonExtensionsTest.kt | 187 ++++++++ .../datasource/sdk/VaultSdkSourceTest.kt | 61 +++ .../vault/manager/VaultLockManagerTest.kt | 9 + .../ui/vault/feature/vault/VaultScreenTest.kt | 84 ++++ .../vault/feature/vault/VaultViewModelTest.kt | 203 +++++++++ .../core/data/manager/model/FlagKey.kt | 9 + .../core/data/manager/model/FlagKeyTest.kt | 5 + .../network/api/AuthenticatedAccountsApi.kt | 7 + .../MasterPasswordAuthenticationDataJson.kt | 19 + .../network/model/UpdateKdfJsonRequest.kt | 25 ++ .../network/service/AccountsService.kt | 6 + .../network/service/AccountsServiceImpl.kt | 6 + .../network/service/AccountsServiceTest.kt | 49 ++ .../components/debug/FeatureFlagListItems.kt | 2 + ui/src/main/res/values/strings.xml | 6 + .../main/res/values/strings_non_localized.xml | 1 + 35 files changed, 1573 insertions(+), 23 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManager.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManagerImpl.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UpdateKdfMinimumsResult.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManagerTest.kt create mode 100644 network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordAuthenticationDataJson.kt create mode 100644 network/src/main/kotlin/com/bitwarden/network/model/UpdateKdfJsonRequest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt index 614629c2e0..b0a7d475da 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.sdk.util import com.bitwarden.crypto.Kdf +import com.bitwarden.network.model.KdfJson import com.bitwarden.network.model.KdfTypeJson import com.bitwarden.network.model.KdfTypeJson.ARGON2_ID import com.bitwarden.network.model.KdfTypeJson.PBKDF2_SHA256 @@ -13,3 +14,23 @@ fun Kdf.toKdfTypeJson(): KdfTypeJson = is Kdf.Argon2id -> ARGON2_ID is Kdf.Pbkdf2 -> PBKDF2_SHA256 } + +/** + * Convert a [Kdf] to [KdfJson] + */ +fun Kdf.toKdfRequestModel(): KdfJson = + when (this) { + is Kdf.Argon2id -> KdfJson( + kdfType = toKdfTypeJson(), + iterations = iterations.toInt(), + memory = memory.toInt(), + parallelism = parallelism.toInt(), + ) + + is Kdf.Pbkdf2 -> KdfJson( + kdfType = toKdfTypeJson(), + iterations = iterations.toInt(), + memory = null, + parallelism = null, + ) + } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManager.kt new file mode 100644 index 0000000000..547723fe26 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManager.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.auth.manager + +import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult + +/** + * An interface to manage the user KDF settings. + */ +interface KdfManager { + + /** + * Checks if user's current KDF settings are below the minimums and needs update + */ + fun needsKdfUpdateToMinimums(): Boolean + + /** + * Updates the user's KDF settings if below the minimums + */ + suspend fun updateKdfToMinimumsIfNeeded(password: String): UpdateKdfMinimumsResult +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManagerImpl.kt new file mode 100644 index 0000000000..e91dad119f --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManagerImpl.kt @@ -0,0 +1,101 @@ +package com.x8bit.bitwarden.data.auth.manager + +import com.bitwarden.core.UpdateKdfResponse +import com.bitwarden.core.data.manager.model.FlagKey +import com.bitwarden.core.data.util.flatMap +import com.bitwarden.crypto.Kdf +import com.bitwarden.network.model.KdfTypeJson +import com.bitwarden.network.model.MasterPasswordAuthenticationDataJson +import com.bitwarden.network.model.MasterPasswordUnlockDataJson +import com.bitwarden.network.model.UpdateKdfJsonRequest +import com.bitwarden.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel +import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult +import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJsonKdfUpdatedMinimums +import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import kotlin.collections.get + +/** + * Default implementation of [KdfManager]. + */ +class KdfManagerImpl( + private val authDiskSource: AuthDiskSource, + private val vaultSdkSource: VaultSdkSource, + private val accountsService: AccountsService, + private val featureFlagManager: FeatureFlagManager, +) : KdfManager { + + private val activeUserId: String? get() = authDiskSource.userState?.activeUserId + + override fun needsKdfUpdateToMinimums(): Boolean { + if (!featureFlagManager.getFeatureFlag(FlagKey.ForceUpdateKdfSettings)) { + return false + } + + val account = authDiskSource + .userState + ?.accounts + ?.get(activeUserId) + ?: return false + + if (account.profile.userDecryptionOptions != null && + !account.profile.userDecryptionOptions.hasMasterPassword + ) { + return false + } + + return account.profile.kdfType == KdfTypeJson.PBKDF2_SHA256 && + account.profile.kdfIterations != null && + account.profile.kdfIterations < DEFAULT_PBKDF2_ITERATIONS + } + + override suspend fun updateKdfToMinimumsIfNeeded(password: String): UpdateKdfMinimumsResult { + val userId = activeUserId ?: return UpdateKdfMinimumsResult.ActiveAccountNotFound + + if (!needsKdfUpdateToMinimums()) { + return UpdateKdfMinimumsResult.Success + } + return vaultSdkSource + .makeUpdateKdf( + userId = userId, + password = password, + kdf = Kdf.Pbkdf2(iterations = DEFAULT_PBKDF2_ITERATIONS.toUInt()), + ) + .flatMap { + accountsService.updateKdf(createUpdateKdfRequest(it)) + } + .fold( + onSuccess = { + authDiskSource.userState = authDiskSource.userState + ?.toUserStateJsonKdfUpdatedMinimums() + UpdateKdfMinimumsResult.Success + }, + onFailure = { UpdateKdfMinimumsResult.Error(error = it) }, + ) + } + + private fun createUpdateKdfRequest(response: UpdateKdfResponse): UpdateKdfJsonRequest { + val authData = response.masterPasswordAuthenticationData + val oldAuthData = response.oldMasterPasswordAuthenticationData + val unlockData = response.masterPasswordUnlockData + + return UpdateKdfJsonRequest( + authenticationData = MasterPasswordAuthenticationDataJson( + kdf = authData.kdf.toKdfRequestModel(), + masterPasswordAuthenticationHash = authData.masterPasswordAuthenticationHash, + salt = authData.salt, + ), + key = unlockData.masterKeyWrappedUserKey, + masterPasswordHash = oldAuthData.masterPasswordAuthenticationHash, + newMasterPasswordHash = authData.masterPasswordAuthenticationHash, + unlockData = MasterPasswordUnlockDataJson( + kdf = unlockData.kdf.toKdfRequestModel(), + masterKeyWrappedUserKey = unlockData.masterKeyWrappedUserKey, + salt = unlockData.salt, + ), + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt index f315bc7126..bd429cf804 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/manager/di/AuthManagerModule.kt @@ -17,6 +17,8 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManagerImpl import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager import com.x8bit.bitwarden.data.auth.manager.AuthTokenManagerImpl +import com.x8bit.bitwarden.data.auth.manager.KdfManager +import com.x8bit.bitwarden.data.auth.manager.KdfManagerImpl import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManagerImpl import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager @@ -25,6 +27,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManagerImpl import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource @@ -143,4 +146,18 @@ object AuthManagerModule { fun providesAuthTokenManager( authDiskSource: AuthDiskSource, ): AuthTokenManager = AuthTokenManagerImpl(authDiskSource = authDiskSource) + + @Provides + @Singleton + fun providesKdfManager( + authDiskSource: AuthDiskSource, + vaultSdkSource: VaultSdkSource, + accountsService: AccountsService, + featureFlagManager: FeatureFlagManager, + ): KdfManager = KdfManagerImpl( + authDiskSource = authDiskSource, + vaultSdkSource = vaultSdkSource, + accountsService = accountsService, + featureFlagManager = featureFlagManager, + ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index a84f8ad994..7718077231 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -6,6 +6,7 @@ import com.bitwarden.network.model.TwoFactorDataModel import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager +import com.x8bit.bitwarden.data.auth.manager.KdfManager import com.x8bit.bitwarden.data.auth.manager.UserStateManager import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult @@ -44,7 +45,11 @@ import kotlinx.coroutines.flow.StateFlow * Provides an API for observing an modifying authentication state. */ @Suppress("TooManyFunctions") -interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateManager { +interface AuthRepository : + AuthenticatorProvider, + AuthRequestManager, + KdfManager, + UserStateManager { /** * Models the current auth state. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 79ba4b9a40..35c98336e9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -53,6 +53,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager +import com.x8bit.bitwarden.data.auth.manager.KdfManager import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager @@ -79,6 +80,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult @@ -129,6 +131,7 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn +import timber.log.Timber import java.time.Clock import javax.inject.Singleton @@ -158,11 +161,13 @@ class AuthRepositoryImpl( private val userLogoutManager: UserLogoutManager, private val policyManager: PolicyManager, private val userStateManager: UserStateManager, + private val kdfManager: KdfManager, logsManager: LogsManager, pushManager: PushManager, dispatcherManager: DispatcherManager, ) : AuthRepository, AuthRequestManager by authRequestManager, + KdfManager by kdfManager, UserStateManager by userStateManager { /** * A scope intended for use when simply collecting multiple flows in order to combine them. The @@ -1681,6 +1686,16 @@ class AuthRepositoryImpl( settingsRepository.hasUserLoggedInOrCreatedAccount = true authDiskSource.userState = userStateJson + password?.let { + // Automatically update kdf to minimums after password unlock and userState update + kdfManager + .updateKdfToMinimumsIfNeeded(password = password) + .also { result -> + if (result is UpdateKdfMinimumsResult.Error) { + Timber.e(result.error, message = "Failed to silent update KDF settings.") + } + } + } loginResponse.key?.let { // Only set the value if it's present, since we may have set it already // when we completed the pending admin auth request. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt index fb42b48da6..765b8e8749 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt @@ -10,6 +10,7 @@ import com.bitwarden.network.service.OrganizationService import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager +import com.x8bit.bitwarden.data.auth.manager.KdfManager import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager @@ -67,6 +68,7 @@ object AuthRepositoryModule { policyManager: PolicyManager, logsManager: LogsManager, userStateManager: UserStateManager, + kdfManager: KdfManager, ): AuthRepository = AuthRepositoryImpl( clock = clock, accountsService = accountsService, @@ -91,6 +93,7 @@ object AuthRepositoryModule { policyManager = policyManager, logsManager = logsManager, userStateManager = userStateManager, + kdfManager = kdfManager, ) @Provides diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UpdateKdfMinimumsResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UpdateKdfMinimumsResult.kt new file mode 100644 index 0000000000..0eb2d5d2b7 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UpdateKdfMinimumsResult.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +/** + * Models result of updating a user's kdf settings to minimums + */ +sealed class UpdateKdfMinimumsResult { + /** + * Active account was not found + */ + object ActiveAccountNotFound : UpdateKdfMinimumsResult() + + /** + * There was an error updating user to minimum kdf settings. + * + * @param error the error. + */ + data class Error( + val error: Throwable?, + ) : UpdateKdfMinimumsResult() + + /** + * Updated user to minimum kdf settings successfully. + */ + object Success : UpdateKdfMinimumsResult() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt index bf75128c9a..78ac8e757c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.repository.util import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault +import com.bitwarden.network.model.KdfTypeJson import com.bitwarden.network.model.OrganizationType import com.bitwarden.network.model.SyncResponseJson import com.bitwarden.network.model.UserDecryptionOptionsJson @@ -12,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import com.x8bit.bitwarden.data.vault.repository.util.statusFor @@ -123,6 +125,30 @@ fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson { ) } +/** + * Updates the [UserStateJson] KDF settings to minimum requirements. + */ +fun UserStateJson.toUserStateJsonKdfUpdatedMinimums(): UserStateJson { + val account = this.activeAccount + val profile = account.profile + val updatedProfile = profile + .copy( + kdfType = KdfTypeJson.PBKDF2_SHA256, + kdfIterations = DEFAULT_PBKDF2_ITERATIONS, + kdfMemory = null, + kdfParallelism = null, + ) + val updatedAccount = account.copy(profile = updatedProfile) + return this + .copy( + accounts = accounts + .toMutableMap() + .apply { + replace(activeUserId, updatedAccount) + }, + ) +} + /** * Converts the given [UserStateJson] to a [UserState] using the given [vaultState]. */ 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 8c4798dd7c..b8e796185a 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 @@ -6,6 +6,7 @@ import com.bitwarden.core.DerivePinKeyResponse import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.InitUserCryptoRequest +import com.bitwarden.core.UpdateKdfResponse import com.bitwarden.core.UpdatePasswordResponse import com.bitwarden.crypto.Kdf import com.bitwarden.crypto.TrustDeviceResponse @@ -491,4 +492,13 @@ interface VaultSdkSource { fido2CredentialStore: Fido2CredentialStore, relyingPartyId: String, ): Result> + + /** + * Updates the KDF settings for the user with the given [userId]. + */ + suspend fun makeUpdateKdf( + userId: String, + password: String, + kdf: Kdf, + ): Result } 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 0d3e459c49..d24be33fdc 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 @@ -7,6 +7,7 @@ import com.bitwarden.core.DeriveKeyConnectorRequest import com.bitwarden.core.DerivePinKeyResponse import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoRequest +import com.bitwarden.core.UpdateKdfResponse import com.bitwarden.core.UpdatePasswordResponse import com.bitwarden.crypto.Kdf import com.bitwarden.crypto.TrustDeviceResponse @@ -608,4 +609,14 @@ class VaultSdkSourceImpl( ) .silentlyDiscoverCredentials(relyingPartyId) } + + override suspend fun makeUpdateKdf( + userId: String, + password: String, + kdf: Kdf, + ): Result = runCatchingWithLogs { + getClient(userId = userId) + .crypto() + .makeUpdateKdf(password = password, kdf = kdf) + } } 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 4e9db8b13c..4a5beb912d 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 @@ -17,9 +17,11 @@ import com.bitwarden.crypto.Kdf import com.bitwarden.data.manager.DispatcherManager import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource +import com.x8bit.bitwarden.data.auth.manager.KdfManager import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager 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.activeUserIdChangesFlow import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens @@ -64,6 +66,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import java.time.Clock import kotlin.time.Duration.Companion.minutes @@ -86,6 +89,7 @@ class VaultLockManagerImpl( private val appStateManager: AppStateManager, private val userLogoutManager: UserLogoutManager, private val trustedDeviceManager: TrustedDeviceManager, + private val kdfManager: KdfManager, dispatcherManager: DispatcherManager, context: Context, ) : VaultLockManager { @@ -230,13 +234,12 @@ class VaultLockManagerImpl( ) } } - } - .also { if (it is VaultUnlockResult.Success) { clearInvalidUnlockCount(userId = userId) trustedDeviceManager .trustThisDeviceIfNecessary(userId = userId) - .also { setVaultToUnlocked(userId = userId) } + updateKdfIfNeeded(initUserCryptoMethod) + setVaultToUnlocked(userId = userId) } else { incrementInvalidUnlockCount(userId = userId) } @@ -673,6 +676,20 @@ class VaultLockManagerImpl( return (accounts.find { it.userId == userId }?.isLoggedIn) == false } + private suspend fun updateKdfIfNeeded(initUserCryptoMethod: InitUserCryptoMethod) { + if (initUserCryptoMethod is InitUserCryptoMethod.Password) { + kdfManager + .updateKdfToMinimumsIfNeeded( + password = initUserCryptoMethod.password, + ) + .also { result -> + if (result is UpdateKdfMinimumsResult.Error) { + Timber.e(result.error, message = "Failed to silent update KDF settings.") + } + } + } + } + /** * A custom [BroadcastReceiver] that listens for when the screen is powered on and restarts the * vault timeout jobs to ensure they complete at the correct time. 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 56e4d184cc..cacfd6e7ff 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 @@ -10,6 +10,7 @@ import com.bitwarden.network.service.SendsService import com.bitwarden.network.service.SyncService import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource +import com.x8bit.bitwarden.data.auth.manager.KdfManager import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource @@ -142,6 +143,7 @@ object VaultManagerModule { userLogoutManager: UserLogoutManager, dispatcherManager: DispatcherManager, trustedDeviceManager: TrustedDeviceManager, + kdfManager: KdfManager, ): VaultLockManager = VaultLockManagerImpl( context = context, @@ -155,6 +157,7 @@ object VaultManagerModule { userLogoutManager = userLogoutManager, dispatcherManager = dispatcherManager, trustedDeviceManager = trustedDeviceManager, + kdfManager = kdfManager, ) @Provides diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenMasterPasswordDialog.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenMasterPasswordDialog.kt index caae5b1991..2cdcb642b2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenMasterPasswordDialog.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/dialog/BitwardenMasterPasswordDialog.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.unit.dp +import com.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.bitwarden.ui.platform.components.button.BitwardenTextButton import com.bitwarden.ui.platform.components.field.BitwardenPasswordField import com.bitwarden.ui.platform.components.model.CardStyle @@ -28,12 +29,20 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme /** * Represents a Bitwarden-styled dialog for entering your master password. * + * @param title The title of the dialog. + * @param message The message of the dialog. + * @param confirmButtonText The text of the confirm button. + * @param dismissButtonText The text of the dismiss button. * @param onConfirmClick called when the confirm button is clicked and emits the entered password. * @param onDismissRequest called when the user attempts to dismiss the dialog (for example by * tapping outside of it). */ @Composable fun BitwardenMasterPasswordDialog( + title: String = stringResource(id = BitwardenString.password_confirmation), + message: String = stringResource(id = BitwardenString.password_confirmation_desc), + confirmButtonText: String = stringResource(id = BitwardenString.submit), + dismissButtonText: String = stringResource(id = BitwardenString.cancel), onConfirmClick: (masterPassword: String) -> Unit, onDismissRequest: () -> Unit, ) { @@ -42,14 +51,14 @@ fun BitwardenMasterPasswordDialog( onDismissRequest = onDismissRequest, dismissButton = { BitwardenTextButton( - label = stringResource(id = BitwardenString.cancel), + label = dismissButtonText, onClick = onDismissRequest, modifier = Modifier.testTag("DismissAlertButton"), ) }, confirmButton = { - BitwardenTextButton( - label = stringResource(id = BitwardenString.submit), + BitwardenFilledButton( + label = confirmButtonText, isEnabled = masterPassword.isNotEmpty(), onClick = { onConfirmClick(masterPassword) }, modifier = Modifier.testTag("AcceptAlertButton"), @@ -57,7 +66,7 @@ fun BitwardenMasterPasswordDialog( }, title = { Text( - text = stringResource(id = BitwardenString.password_confirmation), + text = title, style = BitwardenTheme.typography.headlineSmall, modifier = Modifier.testTag("AlertTitleText"), ) @@ -65,7 +74,7 @@ fun BitwardenMasterPasswordDialog( text = { Column { Text( - text = stringResource(id = BitwardenString.password_confirmation_desc), + text = message, style = BitwardenTheme.typography.bodyMedium, modifier = Modifier.testTag("AlertContentText"), ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 743f0d8ffb..929ae28017 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -59,6 +59,7 @@ import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenPlurals import com.bitwarden.ui.platform.resource.BitwardenString +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog import com.x8bit.bitwarden.ui.platform.composition.LocalAppReviewManager import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager @@ -458,6 +459,18 @@ private fun VaultDialogs( ) } + is VaultState.DialogState.VaultLoadKdfUpdateRequired -> { + BitwardenMasterPasswordDialog( + title = dialogState.title(), + message = dialogState.message(), + dismissButtonText = stringResource(BitwardenString.later), + onConfirmClick = { + vaultHandlers.onKdfUpdatePasswordRepromptSubmit(it) + }, + onDismissRequest = vaultHandlers.dialogDismiss, + ) + } + null -> Unit } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index da80410215..88caf0b1ae 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -25,6 +25,7 @@ import com.bitwarden.vault.DecryptCipherListResult import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager @@ -284,6 +285,10 @@ class VaultViewModel @Inject constructor( handleShareAllCipherDecryptionErrorsClick() } + is VaultAction.KdfUpdatePasswordRepromptSubmit -> { + handleKdfUpdatePasswordRepromptSubmit(action) + } + VaultAction.EnableThirdPartyAutofillClick -> handleEnableThirdPartyAutofillClick() VaultAction.DismissThirdPartyAutofillDialogClick -> { handleDismissThirdPartyAutofillDialogClick() @@ -796,12 +801,53 @@ class VaultViewModel @Inject constructor( handleDecryptionErrorReceive(action) } + is VaultAction.Internal.UpdatedKdfToMinimumsReceived -> { + handleUpdatedKdfToMinimumsReceived(action) + } + is VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive -> { handleCredentialExchangeProtocolExportFlagUpdateReceive(action) } } } + private fun handleUpdatedKdfToMinimumsReceived( + action: VaultAction.Internal.UpdatedKdfToMinimumsReceived, + ) { + mutableStateFlow.update { + it.copy(dialog = null) + } + + when (val result = action.result) { + UpdateKdfMinimumsResult.ActiveAccountNotFound -> { + showGenericError( + message = BitwardenString.kdf_update_failed_active_account_not_found.asText(), + ) + Timber.e(message = "Failed to update kdf to minimums: Active account not found") + } + + is UpdateKdfMinimumsResult.Error -> { + showGenericError( + message = BitwardenString + .an_error_occurred_while_trying_to_update_your_kdf_settings + .asText(), + error = result.error, + ) + Timber.e(result.error, message = "Failed to update kdf to minimums.") + } + + UpdateKdfMinimumsResult.Success -> { + sendEvent( + event = VaultEvent.ShowSnackbar( + data = BitwardenSnackbarData( + message = BitwardenString.encryption_settings_updated.asText(), + ), + ), + ) + } + } + } + private fun handleCredentialExchangeProtocolExportFlagUpdateReceive( action: VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive, ) { @@ -964,22 +1010,15 @@ class VaultViewModel @Inject constructor( } val shouldShowDecryptionAlert = !state.hasShownDecryptionFailureAlert && - vaultData.data.decryptCipherListResult.failures.isNotEmpty() + vaultData.data.decryptCipherListResult.failures.isNotEmpty() && + state.dialog == null updateVaultState( vaultData = vaultData.data, - dialog = if (shouldShowDecryptionAlert || - state.dialog is VaultState.DialogState.VaultLoadCipherDecryptionError - ) { - VaultState.DialogState.VaultLoadCipherDecryptionError( - title = BitwardenString.decryption_error.asText(), - cipherCount = vaultData.data.decryptCipherListResult.failures.size, - ) - } else if (state.dialog is VaultState.DialogState.ThirdPartyBrowserAutofill) { - state.dialog - } else { - null - }, + dialog = getDialogVaultLoaded( + shouldShowDecryptionAlert = shouldShowDecryptionAlert, + vaultData = vaultData, + ), hasShownDecryptionFailureAlert = if (shouldShowDecryptionAlert) { true } else { @@ -988,6 +1027,29 @@ class VaultViewModel @Inject constructor( ) } + private fun getDialogVaultLoaded( + shouldShowDecryptionAlert: Boolean, + vaultData: DataState.Loaded, + ): VaultState.DialogState? = if (authRepository.needsKdfUpdateToMinimums()) { + VaultState.DialogState.VaultLoadKdfUpdateRequired( + title = BitwardenString.update_your_encryption_settings.asText(), + message = BitwardenString + .the_new_recommended_encryption_settings_will_improve_your_account_desc_long + .asText(), + ) + } else if (shouldShowDecryptionAlert || + state.dialog is VaultState.DialogState.VaultLoadCipherDecryptionError + ) { + VaultState.DialogState.VaultLoadCipherDecryptionError( + title = BitwardenString.decryption_error.asText(), + cipherCount = vaultData.data.decryptCipherListResult.failures.size, + ) + } else if (state.dialog is VaultState.DialogState.ThirdPartyBrowserAutofill) { + state.dialog + } else { + null + } + private fun updateVaultState( vaultData: VaultData, dialog: VaultState.DialogState? = null, @@ -1146,6 +1208,30 @@ class VaultViewModel @Inject constructor( is GetCipherResult.Success -> result.cipherView } + + private fun handleKdfUpdatePasswordRepromptSubmit( + action: VaultAction.KdfUpdatePasswordRepromptSubmit, + ) { + viewModelScope.launch { + val result = authRepository.updateKdfToMinimumsIfNeeded(password = action.password) + sendAction(action = VaultAction.Internal.UpdatedKdfToMinimumsReceived(result)) + } + } + + private fun showGenericError( + message: Text = BitwardenString.generic_error_message.asText(), + error: Throwable? = null, + ) { + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = message, + error = error, + ), + ) + } + } } /** @@ -1531,6 +1617,15 @@ data class VaultState( val cipherCount: Int, ) : DialogState() + /** + * Represents a dialog indicating that the user needs to update their kdf settings. + */ + @Parcelize + data class VaultLoadKdfUpdateRequired( + val title: Text, + val message: Text, + ) : DialogState() + /** * Represents an error dialog with the given [title] and [message]. */ @@ -1784,6 +1879,13 @@ sealed class VaultAction { val selectedCipherId: String, ) : VaultAction() + /** + * Click to submit the update kdf password reprompt form. + */ + data class KdfUpdatePasswordRepromptSubmit( + val password: String, + ) : VaultAction() + /** * Click to share all cipher decryption error details. */ @@ -1943,6 +2045,13 @@ sealed class VaultAction { val error: Throwable?, ) : Internal() + /** + * Indicates that a result for updating the kdf has been received. + */ + data class UpdatedKdfToMinimumsReceived( + val result: UpdateKdfMinimumsResult, + ) : Internal() + /** * Indicates that the Credential Exchange Protocol export flag has been updated. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt index d529171bc0..bd812720ec 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/handlers/VaultHandlers.kt @@ -46,6 +46,7 @@ data class VaultHandlers( val dismissFlightRecorderSnackbar: () -> Unit, val onShareCipherDecryptionErrorClick: (selectedCipherId: String) -> Unit, val onShareAllCipherDecryptionErrorsClick: () -> Unit, + val onKdfUpdatePasswordRepromptSubmit: (password: String) -> Unit, val onEnabledThirdPartyAutofillClick: () -> Unit, val onDismissThirdPartyAutofillDialogClick: () -> Unit, ) { @@ -137,6 +138,9 @@ data class VaultHandlers( onDismissThirdPartyAutofillDialogClick = { viewModel.trySendAction(VaultAction.DismissThirdPartyAutofillDialogClick) }, + onKdfUpdatePasswordRepromptSubmit = { + viewModel.trySendAction(VaultAction.KdfUpdatePasswordRepromptSubmit(it)) + }, ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManagerTest.kt new file mode 100644 index 0000000000..a21d9039dd --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/manager/KdfManagerTest.kt @@ -0,0 +1,422 @@ +package com.x8bit.bitwarden.data.auth.manager + +import com.bitwarden.core.MasterPasswordAuthenticationData +import com.bitwarden.core.MasterPasswordUnlockData +import com.bitwarden.core.UpdateKdfResponse +import com.bitwarden.core.data.manager.model.FlagKey +import com.bitwarden.core.data.util.asFailure +import com.bitwarden.core.data.util.asSuccess +import com.bitwarden.crypto.Kdf +import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson +import com.bitwarden.network.model.KdfTypeJson +import com.bitwarden.network.model.UserDecryptionOptionsJson +import com.bitwarden.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson +import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +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 kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.ZonedDateTime + +class KdfManagerTest { + + private val fakeAuthDiskSource = FakeAuthDiskSource() + private val vaultSdkSource: VaultSdkSource = mockk() + private val accountsService: AccountsService = mockk() + private val featureFlagManager: FeatureFlagManager = mockk { + every { getFeatureFlag(FlagKey.ForceUpdateKdfSettings) } returns true + } + + private val manager: KdfManager = KdfManagerImpl( + authDiskSource = fakeAuthDiskSource, + vaultSdkSource = vaultSdkSource, + accountsService = accountsService, + featureFlagManager = featureFlagManager, + ) + + @Test + fun `needsKdfUpdateToMinimums with no active user should return false`() = runTest { + fakeAuthDiskSource.userState = null + + val result = manager.needsKdfUpdateToMinimums() + + assertFalse(result) + } + + @Test + fun `needsKdfUpdateToMinimums with kdfType null should return false`() = runTest { + val nullKdfProfile = PROFILE_1.copy( + kdfType = null, + kdfIterations = null, + kdfMemory = null, + kdfParallelism = null, + ) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy( + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy(profile = nullKdfProfile), + ), + ) + + val result = manager.needsKdfUpdateToMinimums() + + assertFalse(result) + } + + @Test + @Suppress("MaxLineLength") + fun `needsKdfUpdateToMinimums with user decryption options and without password returns false`() = + runTest { + val account = ACCOUNT_1.copy( + profile = ACCOUNT_1.profile.copy( + userDecryptionOptions = UserDecryptionOptionsJson( + hasMasterPassword = false, + keyConnectorUserDecryptionOptions = null, + trustedDeviceUserDecryptionOptions = null, + masterPasswordUnlock = null, + ), + ), + ) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy( + accounts = mapOf( + USER_ID_1 to account, + ), + ) + + val result = manager.needsKdfUpdateToMinimums() + assertFalse(result) + } + + @Test + fun `needsKdfUpdateToMinimums with PBKDF2 below minimum iterations should return true`() = + runTest { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_2 + + val result = manager.needsKdfUpdateToMinimums() + + assertTrue(result) + } + + @Test + fun `needsKdfUpdateToMinimums with PBKDF2 meeting minimum iterations should return false`() = + runTest { + val sufficientIterationsProfile = PROFILE_1.copy( + kdfType = KdfTypeJson.PBKDF2_SHA256, + kdfIterations = 600000, // Meets minimum + kdfMemory = null, + kdfParallelism = null, + ) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy( + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy(profile = sufficientIterationsProfile), + ), + ) + + val result = manager.needsKdfUpdateToMinimums() + + assertFalse(result) + } + + @Test + fun `needsKdfUpdateToMinimums with Argon2id below minimum parameters should return false`() = + runTest { + val lowArgon2idProfile = PROFILE_1.copy( + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 1, // Below minimum of 3 + kdfMemory = 16, // Below minimum of 64 + kdfParallelism = 1, // Below minimum of 4 + ) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy( + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy(profile = lowArgon2idProfile), + ), + ) + + val result = manager.needsKdfUpdateToMinimums() + + assertFalse(result) + } + + @Test + fun `needsKdfUpdateToMinimums with Argon2id meeting minimum parameters should return false`() = + runTest { + val sufficientArgon2idProfile = PROFILE_1.copy( + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, // Meets minimum + kdfMemory = 64, + kdfParallelism = 4, + ) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy( + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy(profile = sufficientArgon2idProfile), + ), + ) + + val result = manager.needsKdfUpdateToMinimums() + + assertFalse(result) + } + + @Test + fun `updateKdfToMinimumsIfNeeded with no active user should return ActiveAccountNotFound`() = + runTest { + fakeAuthDiskSource.userState = null + + val result = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD) + + assertEquals( + UpdateKdfMinimumsResult.ActiveAccountNotFound, + result, + ) + } + + @Test + fun `updateKdfToMinimumsIfNeeded with minimum Kdf iterations should return Success`() = + runTest { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy( + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy( + profile = PROFILE_1.copy( + kdfType = KdfTypeJson.PBKDF2_SHA256, + kdfIterations = 600000, + ), + ), + ), + ) + + val result = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD) + + assertEquals( + UpdateKdfMinimumsResult.Success, + result, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `updateKdfToMinimumsIfNeeded with feature flag ForceUpdateKdfSettings to false return Success`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.ForceUpdateKdfSettings) + } returns false + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy( + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy( + profile = PROFILE_1.copy( + kdfType = KdfTypeJson.PBKDF2_SHA256, + kdfIterations = 1000, + ), + ), + ), + ) + + val result = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD) + + assertEquals( + UpdateKdfMinimumsResult.Success, + result, + ) + } + + @Test + fun `updateKdfToMinimumsIfNeeded if sdk throws an error should return Error`() = runTest { + val error = Throwable("Kdf update failed") + coEvery { + vaultSdkSource.makeUpdateKdf( + userId = any(), + password = any(), + kdf = any(), + ) + } returns error.asFailure() + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_2 + + val result = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD) + + assertEquals( + UpdateKdfMinimumsResult.Error(error = error), + result, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `updateKdfToMinimumsIfNeeded with PBKDF2 below minimums and updateKdf API failure should return Error`() = + runTest { + val error = Throwable("API failed") + coEvery { + vaultSdkSource.makeUpdateKdf( + userId = any(), + password = any(), + kdf = any(), + ) + } returns UPDATE_KDF_RESPONSE.asSuccess() + + coEvery { + accountsService.updateKdf(any()) + } returns error.asFailure() + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_2 + + val result = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD) + + assertEquals(UpdateKdfMinimumsResult.Error(error = error), result) + coVerify(exactly = 1) { + accountsService.updateKdf(any()) + } + } + + @Test + fun `updateKdfToMinimumsIfNeeded with PBKDF2 below minimums should return Success`() = + runTest { + coEvery { + vaultSdkSource.makeUpdateKdf( + userId = any(), + password = any(), + kdf = any(), + ) + } returns UPDATE_KDF_RESPONSE.asSuccess() + + coEvery { + accountsService.updateKdf(any()) + } returns Unit.asSuccess() + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_2 + + val result = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD) + + assertEquals(UpdateKdfMinimumsResult.Success, result) + coVerify(exactly = 1) { + accountsService.updateKdf(any()) + } + } + + @Test + @Suppress("MaxLineLength") + fun `updateKdfToMinimumsIfNeeded with PBKDF2 below minimums should update userState to minimums`() = + runTest { + coEvery { + vaultSdkSource.makeUpdateKdf( + userId = any(), + password = any(), + kdf = any(), + ) + } returns UPDATE_KDF_RESPONSE.asSuccess() + + coEvery { + accountsService.updateKdf(any()) + } returns Unit.asSuccess() + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_2 + + val result = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD) + + assertEquals(UpdateKdfMinimumsResult.Success, result) + + // Verify userState was updated with minimum KDF values + val updatedUserState = fakeAuthDiskSource.userState + val updatedProfile = updatedUserState?.accounts?.get(USER_ID_2)?.profile + assertEquals(KdfTypeJson.PBKDF2_SHA256, updatedProfile?.kdfType) + assertEquals(600000, updatedProfile?.kdfIterations) + assertNull(updatedProfile?.kdfMemory) + assertNull(updatedProfile?.kdfParallelism) + } +} + +private const val EMAIL = "test@bitwarden.com" +private const val EMAIL_2 = "test2@bitwarden.com" +private const val PASSWORD = "password" +private const val USER_ID_1 = "2a135b23-e1fb-42c9-bec3-573857bc8181" +private const val USER_ID_2 = "b9d32ec0-6497-4582-9798-b350f53bfa02" +private val PROFILE_1 = AccountJson.Profile( + userId = USER_ID_1, + email = EMAIL, + isEmailVerified = true, + name = "Bitwarden Tester", + hasPremium = false, + stamp = null, + organizationId = null, + avatarColorHex = null, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, + kdfMemory = 16, + kdfParallelism = 4, + userDecryptionOptions = null, + isTwoFactorEnabled = false, + creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), +) + +private val ACCOUNT_1 = AccountJson( + profile = PROFILE_1, + settings = AccountJson.Settings( + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US, + ), +) + +private val ACCOUNT_2 = AccountJson( + profile = AccountJson.Profile( + userId = USER_ID_2, + email = EMAIL_2, + isEmailVerified = true, + name = "Bitwarden Tester 2", + hasPremium = false, + stamp = null, + organizationId = null, + avatarColorHex = null, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.PBKDF2_SHA256, + kdfIterations = 400000, + kdfMemory = null, + kdfParallelism = null, + userDecryptionOptions = null, + isTwoFactorEnabled = true, + creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), + ), + settings = AccountJson.Settings( + environmentUrlData = EnvironmentUrlDataJson.DEFAULT_EU, + ), +) + +private val SINGLE_USER_STATE_1 = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf( + USER_ID_1 to ACCOUNT_1, + ), +) + +private val SINGLE_USER_STATE_2 = UserStateJson( + activeUserId = USER_ID_2, + accounts = mapOf( + USER_ID_2 to ACCOUNT_2, + ), +) + +private val UPDATE_KDF_RESPONSE = UpdateKdfResponse( + masterPasswordAuthenticationData = MasterPasswordAuthenticationData( + kdf = mockk(relaxed = true), + salt = "mockSalt", + masterPasswordAuthenticationHash = "mockHash", + ), + masterPasswordUnlockData = MasterPasswordUnlockData( + kdf = mockk(relaxed = true), + masterKeyWrappedUserKey = "mockKey", + salt = "mockSalt", + ), + oldMasterPasswordAuthenticationData = MasterPasswordAuthenticationData( + kdf = mockk(relaxed = true), + salt = "mockSalt", + masterPasswordAuthenticationHash = "mockHash", + ), +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 213e7ab650..5e3017b627 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -5,8 +5,11 @@ import com.bitwarden.core.AuthRequestMethod import com.bitwarden.core.AuthRequestResponse import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.KeyConnectorResponse +import com.bitwarden.core.MasterPasswordAuthenticationData +import com.bitwarden.core.MasterPasswordUnlockData import com.bitwarden.core.RegisterKeyResponse import com.bitwarden.core.RegisterTdeKeyResponse +import com.bitwarden.core.UpdateKdfResponse import com.bitwarden.core.UpdatePasswordResponse import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.util.asFailure @@ -74,6 +77,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3 import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4 import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager +import com.x8bit.bitwarden.data.auth.manager.KdfManager import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager @@ -100,6 +104,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult @@ -266,7 +271,12 @@ class AuthRepositoryTest { val blockSlot = slot LoginResult>() coEvery { userStateTransaction(capture(blockSlot)) } coAnswers { blockSlot.captured() } } - + private val kdfManager: KdfManager = mockk { + every { needsKdfUpdateToMinimums() } returns false + coEvery { + updateKdfToMinimumsIfNeeded(password = any()) + } returns UpdateKdfMinimumsResult.Success + } private val repository: AuthRepository = AuthRepositoryImpl( clock = FIXED_CLOCK, accountsService = accountsService, @@ -291,6 +301,7 @@ class AuthRepositoryTest { policyManager = policyManager, logsManager = logsManager, userStateManager = userStateManager, + kdfManager = kdfManager, ) @BeforeEach @@ -2127,6 +2138,18 @@ class AuthRepositoryTest { ) } returns VaultUnlockResult.Success coEvery { vaultRepository.syncIfNecessary() } just runs + coEvery { + vaultSdkSource.makeUpdateKdf( + userId = any(), + password = any(), + kdf = any(), + ) + } returns UPDATE_KDF_RESPONSE.asSuccess() + coEvery { + accountsService.updateKdf( + body = any(), + ) + } returns Unit.asSuccess() every { GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS.toUserState( previousUserState = SINGLE_USER_STATE_2, @@ -7208,5 +7231,23 @@ class AuthRepositoryTest { ), ), ) + + private val UPDATE_KDF_RESPONSE = UpdateKdfResponse( + masterPasswordAuthenticationData = MasterPasswordAuthenticationData( + kdf = mockk(relaxed = true), + salt = "mockSalt", + masterPasswordAuthenticationHash = "mockHash", + ), + masterPasswordUnlockData = MasterPasswordUnlockData( + kdf = mockk(relaxed = true), + masterKeyWrappedUserKey = "mockKey", + salt = "mockSalt", + ), + oldMasterPasswordAuthenticationData = MasterPasswordAuthenticationData( + kdf = mockk(relaxed = true), + salt = "mockSalt", + masterPasswordAuthenticationHash = "mockHash", + ), + ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index 6f6dc96a86..6090cdb607 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -21,6 +21,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType +import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import io.mockk.every @@ -1681,6 +1682,192 @@ class UserStateJsonExtensionsTest { originalUserState.toUpdatedUserStateJson(syncResponse), ) } + + @Test + fun `toUserStateJsonKdfUpdatedMinimums should update KDF settings to minimum values`() { + val originalProfile = AccountJson.Profile( + userId = "activeUserId", + email = "email", + isEmailVerified = true, + name = "name", + stamp = "stamp", + organizationId = null, + avatarColorHex = "avatarColorHex", + hasPremium = true, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, + kdfMemory = 16, + kdfParallelism = 4, + userDecryptionOptions = null, + isTwoFactorEnabled = false, + creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), + ) + val originalAccount = AccountJson( + profile = originalProfile, + tokens = null, + settings = AccountJson.Settings(environmentUrlData = null), + ) + val originalUserState = UserStateJson( + activeUserId = "activeUserId", + accounts = mapOf("activeUserId" to originalAccount), + ) + + val result = originalUserState.toUserStateJsonKdfUpdatedMinimums() + + assertEquals( + UserStateJson( + activeUserId = "activeUserId", + accounts = mapOf( + "activeUserId" to originalAccount.copy( + profile = originalProfile.copy( + kdfType = KdfTypeJson.PBKDF2_SHA256, + kdfIterations = DEFAULT_PBKDF2_ITERATIONS, + kdfMemory = null, + kdfParallelism = null, + ), + ), + ), + ), + result, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `toUserStateJsonKdfUpdatedMinimums should preserve other profile data while updating KDF`() { + val userDecryptionOptions = UserDecryptionOptionsJson( + hasMasterPassword = true, + trustedDeviceUserDecryptionOptions = null, + keyConnectorUserDecryptionOptions = null, + masterPasswordUnlock = null, + ) + val originalProfile = AccountJson.Profile( + userId = "activeUserId", + email = "test@example.com", + isEmailVerified = true, + name = "Test User", + stamp = "securityStamp", + organizationId = "orgId", + avatarColorHex = "#FF0000", + hasPremium = false, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 100000, + kdfMemory = 32, + kdfParallelism = 8, + userDecryptionOptions = userDecryptionOptions, + isTwoFactorEnabled = true, + creationDate = ZonedDateTime.parse("2024-01-01T00:00:00.00Z"), + ) + val originalAccount = AccountJson( + profile = originalProfile, + tokens = null, + settings = AccountJson.Settings( + environmentUrlData = EnvironmentUrlDataJson(base = "https://vault.bitwarden.com"), + ), + ) + val originalUserState = UserStateJson( + activeUserId = "activeUserId", + accounts = mapOf("activeUserId" to originalAccount), + ) + + val result = originalUserState.toUserStateJsonKdfUpdatedMinimums() + + assertEquals( + UserStateJson( + activeUserId = "activeUserId", + accounts = mapOf( + "activeUserId" to originalAccount.copy( + profile = originalProfile.copy( + kdfType = KdfTypeJson.PBKDF2_SHA256, + kdfIterations = DEFAULT_PBKDF2_ITERATIONS, + kdfMemory = null, + kdfParallelism = null, + ), + ), + ), + ), + result, + ) + } + + @Test + fun `toUserStateJsonKdfUpdatedMinimums should only update active user account`() { + val activeProfile = AccountJson.Profile( + userId = "activeUserId", + email = "active@example.com", + isEmailVerified = true, + name = "Active User", + stamp = null, + organizationId = null, + avatarColorHex = null, + hasPremium = true, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, + kdfMemory = 16, + kdfParallelism = 4, + userDecryptionOptions = null, + isTwoFactorEnabled = false, + creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), + ) + val inactiveProfile = AccountJson.Profile( + userId = "inactiveUserId", + email = "inactive@example.com", + isEmailVerified = true, + name = "Inactive User", + stamp = null, + organizationId = null, + avatarColorHex = null, + hasPremium = false, + forcePasswordResetReason = null, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 500000, + kdfMemory = 8, + kdfParallelism = 2, + userDecryptionOptions = null, + isTwoFactorEnabled = false, + creationDate = ZonedDateTime.parse("2024-08-13T01:00:00.00Z"), + ) + val activeAccount = AccountJson( + profile = activeProfile, + tokens = null, + settings = AccountJson.Settings(environmentUrlData = null), + ) + val inactiveAccount = AccountJson( + profile = inactiveProfile, + tokens = null, + settings = AccountJson.Settings(environmentUrlData = null), + ) + val originalUserState = UserStateJson( + activeUserId = "activeUserId", + accounts = mapOf( + "activeUserId" to activeAccount, + "inactiveUserId" to inactiveAccount, + ), + ) + + val result = originalUserState.toUserStateJsonKdfUpdatedMinimums() + + assertEquals( + UserStateJson( + activeUserId = "activeUserId", + accounts = mapOf( + "activeUserId" to activeAccount.copy( + profile = activeProfile.copy( + kdfType = KdfTypeJson.PBKDF2_SHA256, + kdfIterations = DEFAULT_PBKDF2_ITERATIONS, + kdfMemory = null, + kdfParallelism = null, + ), + ), + "inactiveUserId" to inactiveAccount, // Should remain unchanged + ), + ), + result, + ) + } } private val MOCK_MASTER_PASSWORD_UNLOCK_DATA = MasterPasswordUnlockDataJson( 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 4155b81379..842f224392 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 @@ -7,6 +7,9 @@ import com.bitwarden.core.DeriveKeyConnectorRequest import com.bitwarden.core.DerivePinKeyResponse import com.bitwarden.core.InitOrgCryptoRequest import com.bitwarden.core.InitUserCryptoRequest +import com.bitwarden.core.MasterPasswordAuthenticationData +import com.bitwarden.core.MasterPasswordUnlockData +import com.bitwarden.core.UpdateKdfResponse import com.bitwarden.core.UpdatePasswordResponse import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess @@ -1486,6 +1489,64 @@ class VaultSdkSourceTest { ) assertTrue(result.isFailure) } + + @Test + fun `makeUpdateKdf should return results when successful`() = runTest { + val kdf = mockk() + val updateKdfResponse = UpdateKdfResponse( + masterPasswordAuthenticationData = MasterPasswordAuthenticationData( + kdf = kdf, + salt = "mockSalt", + masterPasswordAuthenticationHash = "mockHash", + ), + masterPasswordUnlockData = MasterPasswordUnlockData( + kdf = kdf, + masterKeyWrappedUserKey = "mockKey", + salt = "mockSalt", + ), + oldMasterPasswordAuthenticationData = MasterPasswordAuthenticationData( + kdf = kdf, + salt = "mockSalt", + masterPasswordAuthenticationHash = "mockHash", + ), + ) + coEvery { + clientCrypto.makeUpdateKdf( + password = "mockPassword", + kdf = kdf, + ) + } returns updateKdfResponse + + val result = vaultSdkSource.makeUpdateKdf( + userId = "mockUserId", + password = "mockPassword", + kdf = kdf, + ) + + assertEquals( + updateKdfResponse.asSuccess(), + result, + ) + } + + @Test + fun `makeUpdateKdf should return Failure when Bitwarden exception is thrown`() = + runTest { + val kdf = mockk() + val error = BitwardenException.Crypto(CryptoException.MissingKey("mockException")) + coEvery { + clientCrypto.makeUpdateKdf( + password = "mockPassword", + kdf = kdf, + ) + } throws error + val result = vaultSdkSource.makeUpdateKdf( + userId = "mockUserId", + password = "mockPassword", + kdf = kdf, + ) + assertEquals(error.asFailure(), result) + } } private const val DEFAULT_SIGNATURE = "0987654321ABCDEF" 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 d2f8a9c380..80ce44e5c5 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 @@ -18,10 +18,12 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource +import com.x8bit.bitwarden.data.auth.manager.KdfManager import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.manager.model.LogoutEvent 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.platform.manager.model.AppCreationState import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState @@ -104,6 +106,12 @@ class VaultLockManagerTest { private val realtimeManager: RealtimeManager = mockk { every { elapsedRealtimeMs } returns FIXED_CLOCK.millis() } + private val kdfManager: KdfManager = mockk { + every { needsKdfUpdateToMinimums() } returns false + coEvery { + updateKdfToMinimumsIfNeeded(password = any()) + } returns UpdateKdfMinimumsResult.Success + } private val vaultLockManager: VaultLockManager = VaultLockManagerImpl( context = context, @@ -117,6 +125,7 @@ class VaultLockManagerTest { userLogoutManager = userLogoutManager, trustedDeviceManager = trustedDeviceManager, dispatcherManager = fakeDispatcherManager, + kdfManager = kdfManager, ) @Test diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 02140559b7..d2d96f16e0 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -779,6 +779,90 @@ class VaultScreenTest : BitwardenComposeTest() { } } + @Test + fun `vault load KDF update required dialog should be shown or hidden according to the state`() { + val dialogTitle = "Master Password Update" + val dialogMessage = "Your master password does not meet the current security requirements." + composeTestRule.assertNoDialogExists() + composeTestRule + .onNodeWithText(dialogTitle) + .assertDoesNotExist() + composeTestRule + .onNodeWithText(dialogMessage) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.VaultLoadKdfUpdateRequired( + title = dialogTitle.asText(), + message = dialogMessage.asText(), + ), + ) + } + + composeTestRule + .onNodeWithText(dialogTitle) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(dialogMessage) + .assertIsDisplayed() + } + + @Suppress("MaxLineLength") + @Test + fun `confirm button click on the VaultLoadKdfUpdateRequired dialog should send KdfUpdatePasswordRepromptSubmit`() { + val dialogTitle = "Master Password Update" + val dialogMessage = "Your master password does not meet the current security requirements." + val testPassword = "test_password" + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.VaultLoadKdfUpdateRequired( + title = dialogTitle.asText(), + message = dialogMessage.asText(), + ), + ) + } + + // Enter password in the input field + composeTestRule + .onNodeWithText("Master password") + .performTextInput(testPassword) + + // Click confirm button + composeTestRule + .onNodeWithText("Submit") + .performClick() + + verify { + viewModel.trySendAction( + VaultAction.KdfUpdatePasswordRepromptSubmit(testPassword), + ) + } + } + + @Test + fun `later button click on the VaultLoadKdfUpdateRequired dialog should send DialogDismiss`() { + val dialogTitle = "Master Password Update" + val dialogMessage = "Your master password does not meet the current security requirements." + val laterText = "Later" + mutableStateFlow.update { + it.copy( + dialog = VaultState.DialogState.VaultLoadKdfUpdateRequired( + title = dialogTitle.asText(), + message = dialogMessage.asText(), + ), + ) + } + + composeTestRule + .onNodeWithText(laterText) + .performClick() + + verify { + viewModel.trySendAction(VaultAction.DialogDismiss) + } + } + @Test fun `syncing dialog should be displayed according to state`() { composeTestRule.assertNoDialogExists() diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 1ff3379ec4..0e84772992 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager @@ -148,6 +149,10 @@ class VaultViewModelTest : BaseViewModelTest() { every { hasPendingAccountAddition = any() } just runs every { logout(userId = any(), reason = any()) } just runs every { switchAccount(any()) } answers { switchAccountResult } + every { needsKdfUpdateToMinimums() } returns false + coEvery { + updateKdfToMinimumsIfNeeded(password = any()) + } returns UpdateKdfMinimumsResult.Success } private var mutableFlightRecorderDataFlow = @@ -2860,6 +2865,204 @@ class VaultViewModelTest : BaseViewModelTest() { ) } + @Test + @Suppress("MaxLineLength") + fun `UpdatedKdfToMinimumsReceived with Success should clear dialog and send a ShowSnackbar event`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction( + action = VaultAction.Internal.UpdatedKdfToMinimumsReceived( + result = UpdateKdfMinimumsResult.Success, + ), + ) + assertEquals( + DEFAULT_STATE.copy(dialog = null), + viewModel.stateFlow.value, + ) + assertEquals( + VaultEvent.ShowSnackbar(BitwardenString.encryption_settings_updated.asText()), + awaitItem(), + ) + } + } + + @Test + fun `UpdatedKdfToMinimumsReceived with ActiveAccountNotFound should show error dialog`() = + runTest { + val viewModel = createViewModel() + viewModel.trySendAction( + action = VaultAction.Internal.UpdatedKdfToMinimumsReceived( + result = UpdateKdfMinimumsResult.ActiveAccountNotFound, + ), + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = VaultState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString + .kdf_update_failed_active_account_not_found + .asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `UpdatedKdfToMinimumsReceived with Error should show error dialog`() = runTest { + val testError = Exception("Test error") + val viewModel = createViewModel() + viewModel.trySendAction( + action = VaultAction.Internal.UpdatedKdfToMinimumsReceived( + result = UpdateKdfMinimumsResult.Error(testError), + ), + ) + assertEquals( + DEFAULT_STATE.copy( + dialog = VaultState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString + .an_error_occurred_while_trying_to_update_your_kdf_settings + .asText(), + error = testError, + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `vaultDataStateFlow Loaded with needsKdfUpdateToMinimums true should show KdfUpdateRequired dialog`() = + runTest { + coEvery { authRepository.needsKdfUpdateToMinimums() } returns true + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + decryptCipherListResult = createMockDecryptCipherListResult( + number = 1, + successes = listOf( + createMockCipherListView( + number = 1, + type = CipherListViewType.Login( + createMockLoginListView(number = 1), + ), + ), + createMockCipherListView( + number = 2, + type = CipherListViewType.Login( + createMockLoginListView(number = 2), + ), + ), + ), + failures = emptyList(), + ), + collectionViewList = emptyList(), + folderViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + val viewModel = createViewModel() + + assertEquals( + createMockVaultState( + viewState = VaultState.ViewState.Content( + loginItemsCount = 2, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + favoriteItems = listOf(), + folderItems = listOf(), + collectionItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 0, + totpItemsCount = 2, + itemTypesCount = 5, + sshKeyItemsCount = 0, + showCardGroup = true, + ), + dialog = VaultState.DialogState.VaultLoadKdfUpdateRequired( + title = BitwardenString.update_your_encryption_settings.asText(), + message = BitwardenString.the_new_recommended_encryption_settings_will_improve_your_account_desc_long.asText(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + @Suppress("MaxLineLength") + fun `vaultDataStateFlow Loaded with needsKdfUpdateToMinimums false should not show KdfUpdateRequired dialog`() = + runTest { + coEvery { authRepository.needsKdfUpdateToMinimums() } returns false + mutableVaultDataStateFlow.value = DataState.Loaded( + data = VaultData( + decryptCipherListResult = createMockDecryptCipherListResult( + number = 1, + successes = listOf( + createMockCipherListView( + number = 1, + type = CipherListViewType.Login( + createMockLoginListView(number = 1), + ), + ), + createMockCipherListView( + number = 2, + type = CipherListViewType.Login( + createMockLoginListView(number = 2), + ), + ), + ), + failures = emptyList(), + ), + collectionViewList = emptyList(), + folderViewList = emptyList(), + sendViewList = emptyList(), + ), + ) + val viewModel = createViewModel() + + assertEquals( + createMockVaultState( + viewState = VaultState.ViewState.Content( + loginItemsCount = 2, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + favoriteItems = listOf(), + folderItems = listOf(), + collectionItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 0, + totpItemsCount = 2, + itemTypesCount = 5, + sshKeyItemsCount = 0, + showCardGroup = true, + ), + dialog = null, + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on KdfUpdatePasswordRepromptSubmit should call updateKdfToMinimumsIfNeeded`() = runTest { + val password = "mock_password" + coEvery { + authRepository.updateKdfToMinimumsIfNeeded(password) + } returns UpdateKdfMinimumsResult.Success + + val viewModel = createViewModel() + + viewModel.trySendAction( + action = VaultAction.KdfUpdatePasswordRepromptSubmit(password = password), + ) + + coVerify(exactly = 1) { + authRepository.updateKdfToMinimumsIfNeeded(password) + } + } + @Suppress("MaxLineLength") @Test fun `CredentialExchangeProtocolExportFlagUpdateReceive should register for export when flag is enabled`() = diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt index 2e3efffd1c..afb8d2e8df 100644 --- a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt @@ -32,6 +32,7 @@ sealed class FlagKey { listOf( CredentialExchangeProtocolImport, CredentialExchangeProtocolExport, + ForceUpdateKdfSettings, CipherKeyEncryption, ) } @@ -71,6 +72,14 @@ sealed class FlagKey { override val defaultValue: Boolean = false } + /** + * Data object holding the feature flag key for the Force Update KDF Settings feature. + */ + data object ForceUpdateKdfSettings : FlagKey() { + override val keyName: String = "pm-18021-force-update-kdf-settings" + override val defaultValue: Boolean = false + } + //region Dummy keys for testing /** * Data object holding the key for a [Boolean] flag to be used in tests. diff --git a/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt b/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt index 21efb757d8..aa28de31fc 100644 --- a/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt +++ b/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt @@ -24,6 +24,10 @@ class FlagKeyTest { FlagKey.BitwardenAuthenticationEnabled.keyName, "bitwarden-authentication-enabled", ) + assertEquals( + FlagKey.ForceUpdateKdfSettings.keyName, + "pm-18021-force-update-kdf-settings", + ) } @Test @@ -34,6 +38,7 @@ class FlagKeyTest { FlagKey.CredentialExchangeProtocolExport, FlagKey.CipherKeyEncryption, FlagKey.BitwardenAuthenticationEnabled, + FlagKey.ForceUpdateKdfSettings, ).all { !it.defaultValue }, diff --git a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedAccountsApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedAccountsApi.kt index 2a9f5db3fe..09d10ce5a6 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedAccountsApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedAccountsApi.kt @@ -5,6 +5,7 @@ import com.bitwarden.network.model.DeleteAccountRequestJson import com.bitwarden.network.model.NetworkResult import com.bitwarden.network.model.ResetPasswordRequestJson import com.bitwarden.network.model.SetPasswordRequestJson +import com.bitwarden.network.model.UpdateKdfJsonRequest import com.bitwarden.network.model.VerifyOtpRequestJson import retrofit2.http.Body import retrofit2.http.HTTP @@ -36,6 +37,12 @@ internal interface AuthenticatedAccountsApi { @POST("/accounts/request-otp") suspend fun requestOtp(): NetworkResult + /** + * Update the KDF settings for the current account. + */ + @POST("/accounts/kdf") + suspend fun updateKdf(@Body body: UpdateKdfJsonRequest): NetworkResult + @POST("/accounts/verify-otp") suspend fun verifyOtp( @Body body: VerifyOtpRequestJson, diff --git a/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordAuthenticationDataJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordAuthenticationDataJson.kt new file mode 100644 index 0000000000..acd1ba3c40 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordAuthenticationDataJson.kt @@ -0,0 +1,19 @@ +package com.bitwarden.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents the data used to authenticate with the master password. + */ +@Serializable +data class MasterPasswordAuthenticationDataJson( + @SerialName("Kdf") + val kdf: KdfJson, + + @SerialName("MasterPasswordAuthenticationHash") + val masterPasswordAuthenticationHash: String, + + @SerialName("Salt") + val salt: String, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/model/UpdateKdfJsonRequest.kt b/network/src/main/kotlin/com/bitwarden/network/model/UpdateKdfJsonRequest.kt new file mode 100644 index 0000000000..db970d1030 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/UpdateKdfJsonRequest.kt @@ -0,0 +1,25 @@ +package com.bitwarden.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents the request body used to update the user's kdf settings. + */ +@Serializable +data class UpdateKdfJsonRequest( + @SerialName("authenticationData") + val authenticationData: MasterPasswordAuthenticationDataJson, + + @SerialName("key") + val key: String, + + @SerialName("masterPasswordHash") + val masterPasswordHash: String, + + @SerialName("newMasterPasswordHash") + val newMasterPasswordHash: String, + + @SerialName("unlockData") + val unlockData: MasterPasswordUnlockDataJson, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/service/AccountsService.kt b/network/src/main/kotlin/com/bitwarden/network/service/AccountsService.kt index 457a1dea7a..0a280583ff 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/AccountsService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/AccountsService.kt @@ -8,6 +8,7 @@ import com.bitwarden.network.model.ResendEmailRequestJson import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson import com.bitwarden.network.model.ResetPasswordRequestJson import com.bitwarden.network.model.SetPasswordRequestJson +import com.bitwarden.network.model.UpdateKdfJsonRequest import com.bitwarden.network.model.VerificationCodeResponseJson import com.bitwarden.network.model.VerificationOtpResponseJson @@ -115,4 +116,9 @@ interface AccountsService { accessToken: String, masterKey: String, ): Result + + /** + * Update the KDF settings for the current account. + */ + suspend fun updateKdf(body: UpdateKdfJsonRequest): Result } diff --git a/network/src/main/kotlin/com/bitwarden/network/service/AccountsServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/AccountsServiceImpl.kt index 5ded91fbeb..38de5f66e9 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/AccountsServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/AccountsServiceImpl.kt @@ -16,6 +16,7 @@ import com.bitwarden.network.model.ResendEmailRequestJson import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson import com.bitwarden.network.model.ResetPasswordRequestJson import com.bitwarden.network.model.SetPasswordRequestJson +import com.bitwarden.network.model.UpdateKdfJsonRequest import com.bitwarden.network.model.VerificationCodeResponseJson import com.bitwarden.network.model.VerificationOtpResponseJson import com.bitwarden.network.model.VerifyOtpRequestJson @@ -209,4 +210,9 @@ internal class AccountsServiceImpl( body = KeyConnectorMasterKeyRequestJson(masterKey = masterKey), ) .toResult() + + override suspend fun updateKdf(body: UpdateKdfJsonRequest): Result = + authenticatedAccountsApi + .updateKdf(body) + .toResult() } diff --git a/network/src/test/kotlin/com/bitwarden/network/service/AccountsServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/AccountsServiceTest.kt index edd02d4ba9..7317702340 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/AccountsServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/AccountsServiceTest.kt @@ -6,15 +6,19 @@ import com.bitwarden.network.api.AuthenticatedKeyConnectorApi import com.bitwarden.network.api.UnauthenticatedAccountsApi import com.bitwarden.network.api.UnauthenticatedKeyConnectorApi import com.bitwarden.network.base.BaseServiceTest +import com.bitwarden.network.model.KdfJson import com.bitwarden.network.model.KdfTypeJson import com.bitwarden.network.model.KeyConnectorKeyRequestJson import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson +import com.bitwarden.network.model.MasterPasswordAuthenticationDataJson +import com.bitwarden.network.model.MasterPasswordUnlockDataJson import com.bitwarden.network.model.PasswordHintResponseJson import com.bitwarden.network.model.RegisterRequestJson import com.bitwarden.network.model.ResendEmailRequestJson import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson import com.bitwarden.network.model.ResetPasswordRequestJson import com.bitwarden.network.model.SetPasswordRequestJson +import com.bitwarden.network.model.UpdateKdfJsonRequest import com.bitwarden.network.model.VerificationCodeResponseJson import com.bitwarden.network.model.VerificationOtpResponseJson import kotlinx.coroutines.test.runTest @@ -291,6 +295,25 @@ class AccountsServiceTest : BaseServiceTest() { } @Test + fun `updateKdf success should return Success`() = runTest { + val response = MockResponse().setResponseCode(200) + server.enqueue(response) + + val result = service.updateKdf(body = UPDATE_KDF_REQUEST) + + assertTrue(result.isSuccess) + } + + @Test + fun `updateKdf failure should return Failure`() = runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + + val result = service.updateKdf(body = UPDATE_KDF_REQUEST) + + assertTrue(result.isFailure) + } + fun `resendNewDeviceOtp with 400 response is Error`() = runTest { val response = MockResponse().setResponseCode(400).setBody(INVALID_JSON) server.enqueue(response) @@ -318,3 +341,29 @@ private const val INVALID_JSON = """ "validationErrors": null } """ + +private val UPDATE_KDF_REQUEST = UpdateKdfJsonRequest( + authenticationData = MasterPasswordAuthenticationDataJson( + kdf = KdfJson( + kdfType = KdfTypeJson.PBKDF2_SHA256, + iterations = 7, + memory = 1, + parallelism = 2, + ), + masterPasswordAuthenticationHash = "mockMasterPasswordHash", + salt = "mockSalt", + ), + key = "mockKey", + masterPasswordHash = "mockMasterPasswordHash", + newMasterPasswordHash = "mockNewMasterPasswordHash", + unlockData = MasterPasswordUnlockDataJson( + kdf = KdfJson( + kdfType = KdfTypeJson.PBKDF2_SHA256, + iterations = 7, + memory = 1, + parallelism = 2, + ), + masterKeyWrappedUserKey = "mockMasterPasswordKey", + salt = "mockSalt", + ), + ) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt index 6c3ec3d111..84d29a3602 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt @@ -27,6 +27,7 @@ fun FlagKey.ListItemContent( FlagKey.CredentialExchangeProtocolImport, FlagKey.CredentialExchangeProtocolExport, FlagKey.CipherKeyEncryption, + FlagKey.ForceUpdateKdfSettings, -> { @Suppress("UNCHECKED_CAST") BooleanFlagItem( @@ -71,6 +72,7 @@ private fun FlagKey.getDisplayLabel(): String = when (this) { FlagKey.CredentialExchangeProtocolImport -> stringResource(BitwardenString.cxp_import) FlagKey.CredentialExchangeProtocolExport -> stringResource(BitwardenString.cxp_export) FlagKey.CipherKeyEncryption -> stringResource(BitwardenString.cipher_key_encryption) + FlagKey.ForceUpdateKdfSettings -> stringResource(BitwardenString.force_update_kdf_settings) FlagKey.BitwardenAuthenticationEnabled -> { stringResource(BitwardenString.bitwarden_authentication_enabled) } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index dfd1a990c8..a82f0f19e5 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1091,6 +1091,10 @@ Do you want to switch to this account? URI match detection controls how Bitwarden identifies autofill suggestions.\nWarning: “Starts with” is an advanced option with increased risk of exposing credentials. “Starts with” is an advanced option with increased risk of exposing credentials. “Regular expression” is an advanced option with increased risk of exposing credentials if used incorrectly. + Later + Encryption settings updated + Update your encryption settings + The new recommended encryption settings will improve your account security. Enter your master password to update now. Credential import was cancelled in the selected app. No items have been imported. Import cancelled No items imported @@ -1128,5 +1132,7 @@ Do you want to switch to this account? Passkeys Import Why is this step required? + Kdf update failed, active account not found. Please try again or contact us. + An error occurred while trying to update your kdf settings. Please try again or contact us. The import request could not be processed. diff --git a/ui/src/main/res/values/strings_non_localized.xml b/ui/src/main/res/values/strings_non_localized.xml index 94e5c61ed7..065a3501de 100644 --- a/ui/src/main/res/values/strings_non_localized.xml +++ b/ui/src/main/res/values/strings_non_localized.xml @@ -36,6 +36,7 @@ 2FAS (no password) LastPass (.json) Aegis (.json) + Force update KDF settings