[PM-23278] Add func to update to minimum kdf settings to AuthRepository

This commit is contained in:
André Bispo 2025-09-10 15:51:50 +01:00
parent a0bbc1a313
commit 0ff6bb4aeb
No known key found for this signature in database
GPG Key ID: E5610EF043C76548
5 changed files with 391 additions and 1 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.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,
)
}

View File

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

View File

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

View File

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

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