[PM-23278] Upgrade user KDF settings to minimums (#5955)

Co-authored-by: David Perez <david@livefront.com>
This commit is contained in:
André Bispo 2025-10-09 08:49:22 +01:00 committed by GitHub
parent 44c373a354
commit d98ff6478f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1573 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<List<Fido2CredentialAutofillView>>
/**
* Updates the KDF settings for the user with the given [userId].
*/
suspend fun makeUpdateKdf(
userId: String,
password: String,
kdf: Kdf,
): Result<UpdateKdfResponse>
}

View File

@ -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<UpdateKdfResponse> = runCatchingWithLogs {
getClient(userId = userId)
.crypto()
.makeUpdateKdf(password = password, kdf = kdf)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<VaultData>,
): 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.
*/

View File

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

View File

@ -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<Kdf>(relaxed = true),
salt = "mockSalt",
masterPasswordAuthenticationHash = "mockHash",
),
masterPasswordUnlockData = MasterPasswordUnlockData(
kdf = mockk<Kdf>(relaxed = true),
masterKeyWrappedUserKey = "mockKey",
salt = "mockSalt",
),
oldMasterPasswordAuthenticationData = MasterPasswordAuthenticationData(
kdf = mockk<Kdf>(relaxed = true),
salt = "mockSalt",
masterPasswordAuthenticationHash = "mockHash",
),
)

View File

@ -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<suspend () -> 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<Kdf>(relaxed = true),
salt = "mockSalt",
masterPasswordAuthenticationHash = "mockHash",
),
masterPasswordUnlockData = MasterPasswordUnlockData(
kdf = mockk<Kdf>(relaxed = true),
masterKeyWrappedUserKey = "mockKey",
salt = "mockSalt",
),
oldMasterPasswordAuthenticationData = MasterPasswordAuthenticationData(
kdf = mockk<Kdf>(relaxed = true),
salt = "mockSalt",
masterPasswordAuthenticationHash = "mockHash",
),
)
}
}

View File

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

View File

@ -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<Kdf>()
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<Kdf>()
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"

View File

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

View File

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

View File

@ -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`() =

View File

@ -32,6 +32,7 @@ sealed class FlagKey<out T : Any> {
listOf(
CredentialExchangeProtocolImport,
CredentialExchangeProtocolExport,
ForceUpdateKdfSettings,
CipherKeyEncryption,
)
}
@ -71,6 +72,14 @@ sealed class FlagKey<out T : Any> {
override val defaultValue: Boolean = false
}
/**
* Data object holding the feature flag key for the Force Update KDF Settings feature.
*/
data object ForceUpdateKdfSettings : FlagKey<Boolean>() {
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.

View File

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

View File

@ -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<Unit>
/**
* Update the KDF settings for the current account.
*/
@POST("/accounts/kdf")
suspend fun updateKdf(@Body body: UpdateKdfJsonRequest): NetworkResult<Unit>
@POST("/accounts/verify-otp")
suspend fun verifyOtp(
@Body body: VerifyOtpRequestJson,

View File

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

View File

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

View File

@ -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<Unit>
/**
* Update the KDF settings for the current account.
*/
suspend fun updateKdf(body: UpdateKdfJsonRequest): Result<Unit>
}

View File

@ -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<Unit> =
authenticatedAccountsApi
.updateKdf(body)
.toResult()
}

View File

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

View File

@ -27,6 +27,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.CredentialExchangeProtocolImport,
FlagKey.CredentialExchangeProtocolExport,
FlagKey.CipherKeyEncryption,
FlagKey.ForceUpdateKdfSettings,
-> {
@Suppress("UNCHECKED_CAST")
BooleanFlagItem(
@ -71,6 +72,7 @@ private fun <T : Any> FlagKey<T>.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)
}

View File

@ -1091,6 +1091,10 @@ Do you want to switch to this account?</string>
<string name="default_uri_match_detection_description_advanced_options">URI match detection controls how Bitwarden identifies autofill suggestions.\n<annotation emphasis="bold">Warning:</annotation> “Starts with” is an advanced option with increased risk of exposing credentials.</string>
<string name="advanced_option_with_increased_risk_of_exposing_credentials">“Starts with” is an advanced option with increased risk of exposing credentials.</string>
<string name="advanced_option_increased_risk_exposing_credentials_used_incorrectly">“Regular expression” is an advanced option with increased risk of exposing credentials if used incorrectly.</string>
<string name="later">Later</string>
<string name="encryption_settings_updated">Encryption settings updated</string>
<string name="update_your_encryption_settings">Update your encryption settings</string>
<string name="the_new_recommended_encryption_settings_will_improve_your_account_desc_long">The new recommended encryption settings will improve your account security. Enter your master password to update now.</string>
<string name="import_was_cancelled_in_the_selected_app">Credential import was cancelled in the selected app. No items have been imported.</string>
<string name="import_cancelled">Import cancelled</string>
<string name="no_items_imported">No items imported</string>
@ -1128,5 +1132,7 @@ Do you want to switch to this account?</string>
<string name="passkeys">Passkeys</string>
<string name="import_verb">Import</string>
<string name="why_is_this_step_required">Why is this step required?</string>
<string name="kdf_update_failed_active_account_not_found">Kdf update failed, active account not found. Please try again or contact us.</string>
<string name="an_error_occurred_while_trying_to_update_your_kdf_settings">An error occurred while trying to update your kdf settings. Please try again or contact us.</string>
<string name="the_import_request_could_not_be_processed">The import request could not be processed.</string>
</resources>

View File

@ -36,6 +36,7 @@
<string name="import_format_label_2fas_json">2FAS (no password)</string>
<string name="import_format_label_lastpass_json">LastPass (.json)</string>
<string name="import_format_label_aegis_json">Aegis (.json)</string>
<string name="force_update_kdf_settings">Force update KDF settings</string>
<!-- endregion Debug Menu -->
</resources>