diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt index bdac748110..4cc1b5939e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index e5af8cf3d2..11c2d5360b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -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, - ): 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, 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. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index 87d2781def..a98ad6e77a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -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 { + 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", ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt index 800523675d..bed8d6c546 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/manager/VaultSyncManagerTest.kt @@ -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, + ), ), ), ), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 848af89b7e..618d217799 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -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 { diff --git a/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt index bf134c98c4..13def0829d 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt @@ -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" + } + } } """ diff --git a/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseUtil.kt b/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseUtil.kt index 498c18cd85..11512bbbf5 100644 --- a/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseUtil.kt +++ b/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseUtil.kt @@ -13,7 +13,7 @@ fun createMockSyncResponse( policies: List = listOf(createMockPolicy(number = number)), domains: SyncResponseJson.Domains = createMockDomains(number = number), sends: List = 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, + )