mirror of
https://github.com/bitwarden/android.git
synced 2026-08-26 22:04:07 -05:00
[PM-35117] fix: Getting updated values from KDF before displaying update KDF prompt (#6802)
This commit is contained in:
+9
@@ -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.
|
||||
*/
|
||||
|
||||
+102
-1
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user