diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt index 614629c2e0..f5f45886de 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/util/KdfExtensions.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.sdk.util import com.bitwarden.crypto.Kdf +import com.bitwarden.network.model.KdfJsonRequest 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,22 @@ fun Kdf.toKdfTypeJson(): KdfTypeJson = is Kdf.Argon2id -> ARGON2_ID is Kdf.Pbkdf2 -> PBKDF2_SHA256 } + +/** + * Convert a [Kdf] to [KdfJsonRequest] + */ +fun Kdf.toKdfRequestModel(): KdfJsonRequest = + when (this) { + is Kdf.Argon2id -> KdfJsonRequest( + kdfType = toKdfTypeJson(), + iterations = iterations.toInt(), + memory = memory.toInt(), + parallelism = parallelism.toInt(), + ) + is Kdf.Pbkdf2 -> KdfJsonRequest( + kdfType = toKdfTypeJson(), + iterations = iterations.toInt(), + memory = null, + parallelism = null, + ) + } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index a84f8ad994..9b9173cd39 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -28,6 +28,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 @@ -351,6 +352,16 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM */ suspend fun getPasswordStrength(email: String? = null, password: String): PasswordStrengthResult + /** + * Checks if their current settings are below the minimums and needs update + */ + suspend fun needsKdfUpdateToMinimums(): Boolean + + /** + * Updates the user's KDF settings if their current settings are below the minimums + */ + suspend fun updateKdfToMinimumsIfNeeded(password: String): UpdateKdfMinimumsResult + /** * Validates the master password for the current logged in user. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index ad210fdcbc..c6807d82dd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -15,6 +15,9 @@ import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault import com.bitwarden.network.model.DeleteAccountResponseJson import com.bitwarden.network.model.GetTokenResponseJson import com.bitwarden.network.model.IdentityTokenAuthModel +import com.bitwarden.network.model.KdfTypeJson +import com.bitwarden.network.model.MasterPasswordAuthenticationDataJsonRequest +import com.bitwarden.network.model.MasterPasswordUnlockDataJsonRequest import com.bitwarden.network.model.OrganizationType import com.bitwarden.network.model.PasswordHintResponseJson import com.bitwarden.network.model.PolicyTypeJson @@ -33,6 +36,7 @@ import com.bitwarden.network.model.SyncResponseJson import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson import com.bitwarden.network.model.TwoFactorAuthMethod import com.bitwarden.network.model.TwoFactorDataModel +import com.bitwarden.network.model.UpdateKdfJsonRequest import com.bitwarden.network.model.VerifyEmailTokenRequestJson import com.bitwarden.network.model.VerifyEmailTokenResponseJson import com.bitwarden.network.service.AccountsService @@ -49,6 +53,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel 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.toKdfRequestModel 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.KeyConnectorManager @@ -77,6 +82,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 +135,8 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import java.time.Clock import javax.inject.Singleton +import kotlin.text.set +import kotlin.text.toInt /** * Default implementation of [AuthRepository]. @@ -1212,6 +1220,98 @@ class AuthRepositoryImpl( onFailure = { PasswordStrengthResult.Error(error = it) }, ) + override suspend fun needsKdfUpdateToMinimums(): Boolean { + val account = authDiskSource + .userState + ?.accounts + ?.get(activeUserId) + ?: 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 + val account = authDiskSource.userState?.accounts?.get(userId) + ?: return UpdateKdfMinimumsResult.ActiveAccountNotFound + account.profile + + // Check if needs update kdf + if (!needsKdfUpdateToMinimums()) { + return UpdateKdfMinimumsResult.Success + } + + // Generate updated KDF data + val updateKdfResponse = vaultSdkSource.makeUpdateKdf( + userId = userId, + password = password, + kdf = account.profile.toSdkParams(), + ).getOrElse { error -> + return UpdateKdfMinimumsResult.Error(error = error) + } + + val authData = updateKdfResponse.masterPasswordAuthenticationData + val oldAuthData = updateKdfResponse.oldMasterPasswordAuthenticationData + val unlockData = updateKdfResponse.masterPasswordUnlockData + // Send update to server + val updateKdfRequest = UpdateKdfJsonRequest( + authenticationData = MasterPasswordAuthenticationDataJsonRequest( + kdf = authData.kdf.toKdfRequestModel(), + masterPasswordAuthenticationHash = + authData.masterPasswordAuthenticationHash, + salt = authData.salt, + ), + key = unlockData.masterKeyWrappedUserKey, + masterPasswordHash = oldAuthData.masterPasswordAuthenticationHash, + newMasterPasswordHash = authData.masterPasswordAuthenticationHash, + unlockData = MasterPasswordUnlockDataJsonRequest( + kdf = unlockData.kdf.toKdfRequestModel(), + masterKeyWrappedUserKey = unlockData.masterKeyWrappedUserKey, + salt = unlockData.salt, + ), + ) + + accountsService + .updateKdf(body = updateKdfRequest) + .getOrElse { error -> + return UpdateKdfMinimumsResult.Error(error = error) + } + + // TODO CHECK IF WE NEED TO SAVE NEW VALUES TO STATE + /** + // Update local storage + authDiskSource.storeMasterPasswordHash( + userId = profile.userId, + passwordHash = updateKdfResponse + .masterPasswordAuthenticationData.masterPasswordAuthenticationHash, + ) + authDiskSource.storeUserKey( + userId = profile.userId, + userKey = updateKdfResponse.masterPasswordUnlockData.masterKeyWrappedUserKey, + ) + + // Update profile with new KDF parameters + val updatedProfile = profile.copy( + kdfType = authData.kdf.toKdfRequestModel().kdfType, + kdfIterations = authData.kdf.toKdfRequestModel().iterations, + kdfMemory = authData.kdf.toKdfRequestModel().memory, + kdfParallelism = authData.kdf.toKdfRequestModel().parallelism, + ) + + val updatedUserState = authDiskSource.userState?.copy( + accounts = authDiskSource.userState!!.accounts.toMutableMap().apply { + this[profile.userId] = account.copy(profile = updatedProfile) + } + ) + authDiskSource.userState = updatedUserState + + **/ + + return UpdateKdfMinimumsResult.Success + } + override suspend fun validatePassword(password: String): ValidatePasswordResult { val userId = activeUserId ?: return ValidatePasswordResult.Error(NoActiveUserException()) return authDiskSource diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UpdateKdfMinimumsResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UpdateKdfMinimumsResult.kt new file mode 100644 index 0000000000..13542ec073 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UpdateKdfMinimumsResult.kt @@ -0,0 +1,30 @@ +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() + + /** + * Account with userId was not found + */ + object AccountNotFound : UpdateKdfMinimumsResult() + + /** + * There was an error updating user to minimum kdf settings. + * + * @param error the error. + */ + data class Error( + val error: Throwable?, + ) : UpdateKdfMinimumsResult() + + /** + * Updated user to minimum kdf settings successfully. + */ + object Success : UpdateKdfMinimumsResult() +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 800435db30..06d2792b5b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -5,8 +5,11 @@ import com.bitwarden.core.AuthRequestMethod import com.bitwarden.core.AuthRequestResponse import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.KeyConnectorResponse +import com.bitwarden.core.MasterPasswordAuthenticationData +import com.bitwarden.core.MasterPasswordUnlockData import com.bitwarden.core.RegisterKeyResponse import com.bitwarden.core.RegisterTdeKeyResponse +import com.bitwarden.core.UpdateKdfResponse import com.bitwarden.core.UpdatePasswordResponse import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.util.asFailure @@ -99,6 +102,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 @@ -158,7 +162,8 @@ import java.time.ZonedDateTime import javax.net.ssl.SSLHandshakeException @Suppress("LargeClass") -class AuthRepositoryTest { +class +AuthRepositoryTest { private val dispatcherManager: DispatcherManager = FakeDispatcherManager() private val accountsService: AccountsService = mockk() @@ -6888,6 +6893,212 @@ class AuthRepositoryTest { ) } + @Test + fun `needsKdfUpdateToMinimums with no active user should return false`() = runTest { + fakeAuthDiskSource.userState = null + + val result = repository.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 = repository.needsKdfUpdateToMinimums() + + assertFalse(result) + } + + @Test + fun `needsKdfUpdateToMinimums with PBKDF2 below minimum iterations should return true`() = + runTest { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_2 + + val result = repository.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 = repository.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 = repository.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 = repository.needsKdfUpdateToMinimums() + + assertFalse(result) + } + + @Test + fun `updateKdfToMinimumsIfNeeded with no active user should return ActiveAccountNotFound`() = + runTest { + fakeAuthDiskSource.userState = null + + val result = repository.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 = repository.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 = repository.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 = repository.updateKdfToMinimumsIfNeeded(password = PASSWORD) + + assertEquals(UpdateKdfMinimumsResult.Error(error = error), result) + coVerify(exactly = 1) { + accountsService.updateKdf(any()) + } + } + + @Test + @Suppress("MaxLineLength") + 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 = repository.updateKdfToMinimumsIfNeeded(password = PASSWORD) + + assertEquals(UpdateKdfMinimumsResult.Success, result) + coVerify(exactly = 1) { + accountsService.updateKdf(any()) + } + } + companion object { private val FIXED_CLOCK: Clock = Clock.fixed( Instant.parse("2023-10-27T12:00:00Z"), @@ -7132,5 +7343,23 @@ class AuthRepositoryTest { ), ), ) + + private val UPDATE_KDF_RESPONSE = UpdateKdfResponse( + masterPasswordAuthenticationData = MasterPasswordAuthenticationData( + kdf = mockk(relaxed = true), + salt = "mockSalt", + masterPasswordAuthenticationHash = "mockHash", + ), + masterPasswordUnlockData = MasterPasswordUnlockData( + kdf = mockk(relaxed = true), + masterKeyWrappedUserKey = "mockKey", + salt = "mockSalt", + ), + oldMasterPasswordAuthenticationData = MasterPasswordAuthenticationData( + kdf = mockk(relaxed = true), + salt = "mockSalt", + masterPasswordAuthenticationHash = "mockHash", + ), + ) } }