[PM-35117] fix: Getting updated values from KDF before displaying update KDF prompt (#6802)

This commit is contained in:
aj-rosado
2026-04-16 17:02:31 +00:00
committed by GitHub
parent 7d2bfe1395
commit 0143f93ef3
7 changed files with 299 additions and 24 deletions
@@ -58,6 +58,7 @@ fun UserStateJson.toUpdatedUserStateJson(
val userId = syncProfile.id
val account = this.accounts[userId] ?: return this
val profile = account.profile
val masterPasswordUnlockKdf = syncResponse.userDecryption?.masterPasswordUnlock?.kdf
val userDecryptionOptions = syncResponse
.userDecryption
?.let { syncUserDecryption ->
@@ -83,6 +84,14 @@ fun UserStateJson.toUpdatedUserStateJson(
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
kdfType = masterPasswordUnlockKdf?.kdfType
?: profile.kdfType,
kdfIterations = masterPasswordUnlockKdf?.iterations
?: profile.kdfIterations,
kdfMemory = masterPasswordUnlockKdf?.memory
?: profile.kdfMemory,
kdfParallelism = masterPasswordUnlockKdf?.parallelism
?: profile.kdfParallelism,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this
@@ -170,8 +170,18 @@ class VaultViewModel @Inject constructor(
get() = state.vaultFilterData?.selectedVaultFilterType ?: VaultFilterType.AllVaults
init {
// Attempt a sync each time we are on a fresh Vault Screen.
vaultRepository.syncIfNecessary()
// Force a sync if a KDF update is detected as necessary to ensure we have the latest KDF
// settings from the server. This handles the case where the user updated their KDF
// settings on another device. Otherwise, attempt a sync based on the normal criteria.
if (authRepository.needsKdfUpdateToMinimums()) {
mutableStateFlow.update { it.copy(isAwaitingKdfSync = true) }
viewModelScope.launch {
vaultRepository.syncForResult(forced = true)
sendAction(VaultAction.Internal.KdfSyncCompletedReceive)
}
} else {
vaultRepository.syncIfNecessary()
}
// Reset the current vault filter type for the current user
vaultRepository.vaultFilterType = vaultFilterTypeOrDefault
@@ -965,6 +975,10 @@ class VaultViewModel @Inject constructor(
handleUpdatedKdfToMinimumsReceived(action)
}
is VaultAction.Internal.KdfSyncCompletedReceive -> {
handleKdfSyncCompletedReceive()
}
is VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive -> {
handleCredentialExchangeProtocolExportFlagUpdateReceive(action)
}
@@ -985,6 +999,23 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleKdfSyncCompletedReceive() {
mutableStateFlow.update { it.copy(isAwaitingKdfSync = false) }
if (authRepository.needsKdfUpdateToMinimums()) {
mutableStateFlow.update {
@Suppress("MaxLineLength")
it.copy(
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(),
),
)
}
}
}
private fun handleUpdatedKdfToMinimumsReceived(
action: VaultAction.Internal.UpdatedKdfToMinimumsReceived,
) {
@@ -1274,25 +1305,26 @@ 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
}
): VaultState.DialogState? =
if (!state.isAwaitingKdfSync && 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,
@@ -1516,6 +1548,7 @@ data class VaultState(
val restrictItemTypesPolicyOrgIds: List<String>,
val isIntroducingArchiveActionCardDismissed: Boolean,
val isPremiumUpgradeBannerEligible: Boolean = false,
val isAwaitingKdfSync: Boolean = false,
) : Parcelable {
/**
@@ -2385,6 +2418,11 @@ sealed class VaultAction {
val result: UpdateKdfMinimumsResult,
) : Internal()
/**
* Indicates the forced sync triggered for a KDF update check has completed.
*/
data object KdfSyncCompletedReceive : Internal()
/**
* Indicates that the Credential Exchange Protocol export flag has been updated.
*/
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.KdfJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorUserDecryptionOptionsJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
@@ -1750,6 +1751,10 @@ class UserStateJsonExtensionsTest {
hasPremium = false,
isTwoFactorEnabled = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
kdfType = KdfTypeJson.PBKDF2_SHA256,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
@@ -1833,6 +1838,10 @@ class UserStateJsonExtensionsTest {
hasPremium = true,
isTwoFactorEnabled = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
kdfType = KdfTypeJson.PBKDF2_SHA256,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = trustedDeviceOptions,
@@ -1922,6 +1931,93 @@ class UserStateJsonExtensionsTest {
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should update KDF settings when sync response provides updated values`() {
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremium = false,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.PBKDF2_SHA256,
kdfIterations = 100_000,
kdfMemory = null,
kdfParallelism = null,
userDecryptionOptions = null,
isTwoFactorEnabled = false,
creationDate = Instant.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 syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns null
every { securityStamp } returns null
every { isPremium } returns false
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
val updatedKdf = KdfJson(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = DEFAULT_PBKDF2_ITERATIONS,
memory = null,
parallelism = null,
)
val updatedMasterPasswordUnlock = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = updatedKdf,
masterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey",
)
every { userDecryption } returns UserDecryptionJson(
masterPasswordUnlock = updatedMasterPasswordUnlock,
)
}
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
kdfIterations = DEFAULT_PBKDF2_ITERATIONS,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
masterPasswordUnlock = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = KdfJson(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = DEFAULT_PBKDF2_ITERATIONS,
memory = null,
parallelism = null,
),
masterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey",
),
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
),
),
),
),
),
originalUserState.toUpdatedUserStateJson(syncResponse),
)
}
@Test
fun `toUserStateJsonKdfUpdatedMinimums should update KDF settings to minimum values`() {
val originalProfile = AccountJson.Profile(
@@ -2111,6 +2207,11 @@ class UserStateJsonExtensionsTest {
private val MOCK_MASTER_PASSWORD_UNLOCK_DATA = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = mockk(),
kdf = KdfJson(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = 600_000,
memory = null,
parallelism = null,
),
masterKeyWrappedUserKey = "masterKeyWrappedUserKeyMock",
)
@@ -10,11 +10,14 @@ import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.network.model.createMockCipher
import com.bitwarden.network.model.createMockCollection
import com.bitwarden.network.model.createMockDomains
import com.bitwarden.network.model.createMockFolder
import com.bitwarden.network.model.createMockMasterPasswordUnlock
import com.bitwarden.network.model.createMockOrganizationKeys
import com.bitwarden.network.model.createMockOrganizationNetwork
import com.bitwarden.network.model.createMockPolicy
@@ -728,6 +731,16 @@ class VaultSyncManagerTest {
profile = MOCK_PROFILE.copy(
avatarColorHex = "mockAvatarColor-1",
stamp = "mockSecurityStamp-1",
kdfType = KdfTypeJson.PBKDF2_SHA256,
kdfIterations = 600000,
kdfMemory = null,
kdfParallelism = null,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
masterPasswordUnlock = createMockMasterPasswordUnlock(number = 1),
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
),
),
),
),
@@ -60,6 +60,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockLoginView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCipher
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
@@ -186,6 +187,9 @@ class VaultViewModelTest : BaseViewModelTest() {
every { vaultFilterType = any() } just runs
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
every { sync(forced = any()) } just runs
coEvery { syncForResult(forced = any()) } returns SyncVaultDataResult.Success(
itemsAvailable = true,
)
every { syncIfNecessary() } just runs
every { lockVaultForCurrentUser(any()) } just runs
every { lockVault(any(), any()) } just runs
@@ -270,6 +274,71 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `initial state should trigger a forced sync when KDF update is required`() = runTest {
every { authRepository.needsKdfUpdateToMinimums() } returns true
createViewModel()
coVerify { vaultRepository.syncForResult(forced = true) }
verify(exactly = 0) { vaultRepository.syncIfNecessary() }
}
@Test
fun `KDF dialog is suppressed while sync is pending even when vault data loads`() = runTest {
every { authRepository.needsKdfUpdateToMinimums() } returns true
coEvery { vaultRepository.syncForResult(forced = any()) } just awaits
mutableVaultDataStateFlow.value = DataState.Loaded(
data = VaultData(
decryptCipherListResult = createMockDecryptCipherListResult(
number = 1,
successes = emptyList(),
failures = emptyList(),
),
collectionViewList = emptyList(),
folderViewList = emptyList(),
sendViewList = emptyList(),
),
)
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(
dialog = null,
viewState = VaultState.ViewState.NoItems,
isAwaitingKdfSync = true,
),
viewModel.stateFlow.value,
)
}
@Test
fun `KDF dialog is not shown after sync completes when KDF update is no longer needed`() =
runTest {
every { authRepository.needsKdfUpdateToMinimums() } returns true andThen false
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(dialog = null),
viewModel.stateFlow.value,
)
}
@Test
fun `KDF dialog is shown after sync completes when KDF update is still needed`() =
runTest {
every { authRepository.needsKdfUpdateToMinimums() } returns true
val viewModel = createViewModel()
@Suppress("MaxLineLength")
assertEquals(
DEFAULT_STATE.copy(
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
fun `IntroducingArchiveActionCardDismissedFlow updates should update the state accordingly`() =
runTest {
@@ -391,6 +391,18 @@ private const val SYNC_SUCCESS_JSON = """
"expirationDate": "2023-10-27T12:00:00.00Z",
"authType": 1
}
]
],
"userDecryption": {
"masterPasswordUnlock": {
"kdf": {
"kdfType": 0,
"iterations": 600000,
"memory": null,
"parallelism": null
},
"masterKeyWrappedUserKey": "mockMasterKeyWrappedUserKey-1",
"salt": "mockSalt-1"
}
}
}
"""
@@ -13,7 +13,7 @@ fun createMockSyncResponse(
policies: List<SyncResponseJson.Policy> = listOf(createMockPolicy(number = number)),
domains: SyncResponseJson.Domains = createMockDomains(number = number),
sends: List<SyncResponseJson.Send> = listOf(createMockSend(number = number)),
userDecryption: UserDecryptionJson? = null,
userDecryption: UserDecryptionJson? = createMockUserDecryption(number = number),
): SyncResponseJson =
SyncResponseJson(
folders = folders,
@@ -25,3 +25,36 @@ fun createMockSyncResponse(
sends = sends,
userDecryption = userDecryption,
)
/**
* Create a mock [UserDecryptionJson] with a given [number].
*/
fun createMockUserDecryption(
number: Int,
masterPasswordUnlock: MasterPasswordUnlockDataJson? = createMockMasterPasswordUnlock(
number = number,
),
): UserDecryptionJson =
UserDecryptionJson(
masterPasswordUnlock = masterPasswordUnlock,
)
/**
* Create a mock [MasterPasswordUnlockDataJson] with a given [number].
*/
fun createMockMasterPasswordUnlock(
number: Int,
kdf: KdfJson = KdfJson(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = 600_000,
memory = null,
parallelism = null,
),
masterKeyWrappedUserKey: String = "mockMasterKeyWrappedUserKey-$number",
salt: String = "mockSalt-$number",
): MasterPasswordUnlockDataJson =
MasterPasswordUnlockDataJson(
kdf = kdf,
masterKeyWrappedUserKey = masterKeyWrappedUserKey,
salt = salt,
)