Compare commits

...

9 Commits

Author SHA1 Message Date
André Bispo
59332ac8c7 [PM-25933] Replace sdk updatePassword call with makeUpdatePassword 2025-09-18 15:43:04 +01:00
André Bispo
7c5a6ac2d8 [PM-23278] Check and show if user needs KDF update on vault load. 2025-09-18 10:56:16 +01:00
André Bispo
d07e8f9ae2 Merge branch 'main' into pm-23278/kdf-upgrade-minimum 2025-09-12 17:02:12 +01:00
André Bispo
98d03614f4 [PM-23278] Add dialog to input password to update KDF settings to minimums 2025-09-11 16:37:29 +01:00
André Bispo
6300d5d789 [PM-23278] Add customisation to BitwardenMasterPasswordDialog 2025-09-11 16:34:20 +01:00
André Bispo
b200af36a0 [PM-23278] Remove unused imports 2025-09-10 16:00:23 +01:00
André Bispo
0ff6bb4aeb [PM-23278] Add func to update to minimum kdf settings to AuthRepository 2025-09-10 15:51:50 +01:00
André Bispo
a0bbc1a313 [PM-23278] Add updateKdf service call and test 2025-09-09 10:14:24 +01:00
André Bispo
5ff44d6133 [PM-23278] Add SDK makeUpdateKdf call 2025-09-09 09:58:05 +01:00
22 changed files with 847 additions and 13 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
@@ -1212,6 +1218,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

@@ -6,6 +6,7 @@ import com.bitwarden.core.DerivePinKeyResponse
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.UpdateKdfResponse
import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.crypto.Kdf
import com.bitwarden.crypto.TrustDeviceResponse
@@ -491,4 +492,13 @@ interface VaultSdkSource {
fido2CredentialStore: Fido2CredentialStore,
relyingPartyId: String,
): Result<List<Fido2CredentialAutofillView>>
/**
* Updates the KDF settings for the user with the given [userId].
*/
suspend fun makeUpdateKdf(
userId: String,
password: String,
kdf: Kdf,
): Result<UpdateKdfResponse>
}

View File

@@ -6,6 +6,7 @@ import com.bitwarden.core.DeriveKeyConnectorRequest
import com.bitwarden.core.DerivePinKeyResponse
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.UpdateKdfResponse
import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.crypto.Kdf
import com.bitwarden.crypto.TrustDeviceResponse
@@ -474,7 +475,7 @@ class VaultSdkSourceImpl(
): Result<UpdatePasswordResponse> = runCatchingWithLogs {
getClient(userId = userId)
.crypto()
.updatePassword(newPassword = newPassword)
.makeUpdatePassword(newPassword = newPassword)
}
override suspend fun exportVaultDataToString(
@@ -604,4 +605,14 @@ class VaultSdkSourceImpl(
)
.silentlyDiscoverCredentials(relyingPartyId)
}
override suspend fun makeUpdateKdf(
userId: String,
password: String,
kdf: Kdf,
): Result<UpdateKdfResponse> = runCatchingWithLogs {
getClient(userId = userId)
.crypto()
.makeUpdateKdf(password = password, kdf = kdf)
}
}

View File

@@ -19,6 +19,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.bitwarden.ui.platform.components.model.CardStyle
@@ -28,36 +29,55 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Represents a Bitwarden-styled dialog for entering your master password.
*
* @param title The title of the dialog.
* @param message The message of the dialog.
* @param confirmButtonText The text of the confirm button.
* @param dismissButtonText The text of the dismiss button.
* @param onConfirmClick called when the confirm button is clicked and emits the entered password.
* @param onDismissRequest called when the user attempts to dismiss the dialog (for example by
* tapping outside of it).
*/
@Suppress("LongMethod")
@Composable
fun BitwardenMasterPasswordDialog(
title: String = stringResource(id = BitwardenString.password_confirmation),
message: String = stringResource(id = BitwardenString.password_confirmation_desc),
confirmButtonText: String = stringResource(id = BitwardenString.submit),
dismissButtonText: String = stringResource(id = BitwardenString.cancel),
onConfirmClick: (masterPassword: String) -> Unit,
onDismissRequest: () -> Unit,
filledButtonStyle: Boolean = false,
) {
var masterPassword by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
BitwardenTextButton(
label = stringResource(id = BitwardenString.cancel),
label = dismissButtonText,
onClick = onDismissRequest,
modifier = Modifier.testTag("DismissAlertButton"),
)
},
confirmButton = {
BitwardenTextButton(
label = stringResource(id = BitwardenString.submit),
isEnabled = masterPassword.isNotEmpty(),
onClick = { onConfirmClick(masterPassword) },
modifier = Modifier.testTag("AcceptAlertButton"),
)
if (filledButtonStyle) {
BitwardenFilledButton(
label = confirmButtonText,
isEnabled = masterPassword.isNotEmpty(),
onClick = { onConfirmClick(masterPassword) },
modifier = Modifier.testTag("AcceptAlertButton"),
)
} else {
BitwardenTextButton(
label = confirmButtonText,
isEnabled = masterPassword.isNotEmpty(),
onClick = { onConfirmClick(masterPassword) },
modifier = Modifier.testTag("AcceptAlertButton"),
)
}
},
title = {
Text(
text = stringResource(id = BitwardenString.password_confirmation),
text = title,
style = BitwardenTheme.typography.headlineSmall,
modifier = Modifier.testTag("AlertTitleText"),
)
@@ -65,7 +85,7 @@ fun BitwardenMasterPasswordDialog(
text = {
Column {
Text(
text = stringResource(id = BitwardenString.password_confirmation_desc),
text = message,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier.testTag("AlertContentText"),
)

View File

@@ -59,6 +59,7 @@ import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog
import com.x8bit.bitwarden.ui.platform.composition.LocalAppReviewManager
import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
@@ -404,6 +405,7 @@ private fun VaultScreenScaffold(
}
}
@Suppress("LongMethod")
@Composable
private fun VaultDialogs(
dialogState: VaultState.DialogState?,
@@ -462,6 +464,19 @@ private fun VaultDialogs(
)
}
is VaultState.DialogState.VaultLoadKdfUpdateRequired -> {
BitwardenMasterPasswordDialog(
title = dialogState.title(),
message = dialogState.message(),
dismissButtonText = stringResource(BitwardenString.later),
onConfirmClick = {
vaultHandlers.onKdfUpdatePasswordRepromptSubmit(it)
},
onDismissRequest = vaultHandlers.dialogDismiss,
filledButtonStyle = true,
)
}
null -> Unit
}
}

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault
import android.os.Parcelable
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.util.persistentListOfNotNull
import com.bitwarden.data.repository.util.baseIconUrl
@@ -24,6 +25,7 @@ import com.bitwarden.vault.DecryptCipherListResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
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.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
@@ -97,6 +99,7 @@ class VaultViewModel @Inject constructor(
private val reviewPromptManager: ReviewPromptManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val networkConnectionManager: NetworkConnectionManager,
private val toastManager: ToastManager,
snackbarRelayManager: SnackbarRelayManager,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
initialState = run {
@@ -256,6 +259,10 @@ class VaultViewModel @Inject constructor(
VaultAction.ShareAllCipherDecryptionErrorsClick -> {
handleShareAllCipherDecryptionErrorsClick()
}
is VaultAction.KdfUpdatePasswordRepromptSubmit -> {
handleKdfUpdatePasswordRepromptSubmit(action)
}
}
}
@@ -909,7 +916,8 @@ class VaultViewModel @Inject constructor(
}
val shouldShowDecryptionAlert = !state.hasShownDecryptionFailureAlert &&
vaultData.data.decryptCipherListResult.failures.isNotEmpty()
vaultData.data.decryptCipherListResult.failures.isNotEmpty() &&
state.dialog == null
updateVaultState(
vaultData = vaultData.data,
@@ -929,6 +937,21 @@ class VaultViewModel @Inject constructor(
state.hasShownDecryptionFailureAlert
},
)
// Check if user needs to update kdf settings to minimums
viewModelScope.launch {
if (authRepository.needsKdfUpdateToMinimums()) {
mutableStateFlow.update { currentState ->
@Suppress("MaxLineLength")
currentState.copy(
dialog = VaultState.DialogState.VaultLoadKdfUpdateRequired(
title = BitwardenString.update_your_encryption_settings.asText(),
message = BitwardenString.the_new_recommended_encryption_settings_will_improve_your_account_security_desc_long.asText(),
),
)
}
}
}
}
private fun updateVaultState(
@@ -1089,6 +1112,48 @@ class VaultViewModel @Inject constructor(
is GetCipherResult.Success -> result.cipherView
}
private fun handleKdfUpdatePasswordRepromptSubmit(
action: VaultAction.KdfUpdatePasswordRepromptSubmit,
) {
mutableStateFlow.update {
it.copy(dialog = null)
}
viewModelScope.launch {
val result = authRepository.updateKdfToMinimumsIfNeeded(action.password)
when (result) {
UpdateKdfMinimumsResult.AccountNotFound -> {
showGenericError()
Timber.e(message = "Failed to update kdf to minimums: Account not found")
}
UpdateKdfMinimumsResult.ActiveAccountNotFound -> {
showGenericError()
Timber.e(message = "Failed to update kdf to minimums: Active account not found")
}
is UpdateKdfMinimumsResult.Error -> {
showGenericError(error = result.error)
Timber.e(message = "Failed to update kdf to minimums: ${result.error}")
}
UpdateKdfMinimumsResult.Success -> {
toastManager.show(messageId = BitwardenString.encryption_settings_updated)
}
}
}
}
private fun showGenericError(
error: Throwable? = null,
) {
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.Error(
BitwardenString.an_error_has_occurred.asText(),
BitwardenString.generic_error_message.asText(),
error = error,
),
)
}
}
}
/**
@@ -1466,6 +1531,15 @@ data class VaultState(
val cipherCount: Int,
) : DialogState()
/**
* Represents a dialog indicating that the user needs to update their kdf settings.
*/
@Parcelize
data class VaultLoadKdfUpdateRequired(
val title: Text,
val message: Text,
) : DialogState()
/**
* Represents an error dialog with the given [title] and [message].
*/
@@ -1715,6 +1789,13 @@ sealed class VaultAction {
val selectedCipherId: String,
) : VaultAction()
/**
* Click to submit the update kdf password reprompt form.
*/
data class KdfUpdatePasswordRepromptSubmit(
val password: String,
) : VaultAction()
/**
* Click to share all cipher decryption error details.
*/

View File

@@ -47,6 +47,7 @@ data class VaultHandlers(
val dismissFlightRecorderSnackbar: () -> Unit,
val onShareCipherDecryptionErrorClick: (selectedCipherId: String) -> Unit,
val onShareAllCipherDecryptionErrorsClick: () -> Unit,
val onKdfUpdatePasswordRepromptSubmit: (password: String) -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
@@ -139,6 +140,12 @@ data class VaultHandlers(
VaultAction.ShareAllCipherDecryptionErrorsClick,
)
},
onKdfUpdatePasswordRepromptSubmit =
{
viewModel.trySendAction(
VaultAction.KdfUpdatePasswordRepromptSubmit(it),
)
},
)
}
}

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

View File

@@ -6,6 +6,9 @@ import com.bitwarden.core.DeriveKeyConnectorRequest
import com.bitwarden.core.DerivePinKeyResponse
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.MasterPasswordAuthenticationData
import com.bitwarden.core.MasterPasswordUnlockData
import com.bitwarden.core.UpdateKdfResponse
import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
@@ -1079,7 +1082,7 @@ class VaultSdkSourceTest {
newKey = newKey,
)
coEvery {
clientCrypto.updatePassword(
clientCrypto.makeUpdatePassword(
newPassword = newPassword,
)
} returns updatePasswordResponse
@@ -1427,6 +1430,63 @@ class VaultSdkSourceTest {
)
assertTrue(result.isFailure)
}
@Test
fun `makeUpdateKdf should return results when successful`() = runTest {
val kdf = mockk<Kdf>()
val updateKdfResponse = UpdateKdfResponse(
masterPasswordAuthenticationData = MasterPasswordAuthenticationData(
kdf = kdf,
salt = "mockSalt",
masterPasswordAuthenticationHash = "mockHash",
),
masterPasswordUnlockData = MasterPasswordUnlockData(
kdf = kdf,
masterKeyWrappedUserKey = "mockKey",
salt = "mockSalt",
),
oldMasterPasswordAuthenticationData = MasterPasswordAuthenticationData(
kdf = kdf,
salt = "mockSalt",
masterPasswordAuthenticationHash = "mockHash",
),
)
coEvery {
clientCrypto.makeUpdateKdf(
password = "mockPassword",
kdf = kdf,
)
} returns updateKdfResponse
val result = vaultSdkSource.makeUpdateKdf(
userId = "mockUserId",
password = "mockPassword",
kdf = kdf,
)
assertEquals(
updateKdfResponse.asSuccess(),
result,
)
}
@Test
fun `makeUpdateKdf should return Failure when Bitwarden exception is thrown`() =
runTest {
val kdf = mockk<Kdf>()
coEvery {
clientCrypto.makeUpdateKdf(
password = "mockPassword",
kdf = kdf,
)
} throws BitwardenException.E("mockException")
val result = vaultSdkSource.makeUpdateKdf(
userId = "mockUserId",
password = "mockPassword",
kdf = kdf,
)
assertTrue(result.isFailure)
}
}
private const val DEFAULT_SIGNATURE = "0987654321ABCDEF"

View File

@@ -784,6 +784,90 @@ class VaultScreenTest : BitwardenComposeTest() {
}
}
@Test
fun `vault load KDF update required dialog should be shown or hidden according to the state`() {
val dialogTitle = "Master Password Update"
val dialogMessage = "Your master password does not meet the current security requirements."
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText(dialogTitle)
.assertDoesNotExist()
composeTestRule
.onNodeWithText(dialogMessage)
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.VaultLoadKdfUpdateRequired(
title = dialogTitle.asText(),
message = dialogMessage.asText(),
),
)
}
composeTestRule
.onNodeWithText(dialogTitle)
.assertIsDisplayed()
composeTestRule
.onNodeWithText(dialogMessage)
.assertIsDisplayed()
}
@Suppress("MaxLineLength")
@Test
fun `confirm button click on the VaultLoadKdfUpdateRequired dialog should send KdfUpdatePasswordRepromptSubmit`() {
val dialogTitle = "Master Password Update"
val dialogMessage = "Your master password does not meet the current security requirements."
val testPassword = "test_password"
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.VaultLoadKdfUpdateRequired(
title = dialogTitle.asText(),
message = dialogMessage.asText(),
),
)
}
// Enter password in the input field
composeTestRule
.onNodeWithText("Master password")
.performTextInput(testPassword)
// Click confirm button
composeTestRule
.onNodeWithText("Submit")
.performClick()
verify {
viewModel.trySendAction(
VaultAction.KdfUpdatePasswordRepromptSubmit(testPassword),
)
}
}
@Test
fun `later button click on the VaultLoadKdfUpdateRequired dialog should send DialogDismiss`() {
val dialogTitle = "Master Password Update"
val dialogMessage = "Your master password does not meet the current security requirements."
val laterText = "Later"
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.VaultLoadKdfUpdateRequired(
title = dialogTitle.asText(),
message = dialogMessage.asText(),
),
)
}
composeTestRule
.onNodeWithText(laterText)
.performClick()
verify {
viewModel.trySendAction(VaultAction.DialogDismiss)
}
}
@Test
fun `syncing dialog should be displayed according to state`() {
composeTestRule.assertNoDialogExists()

View File

@@ -5,6 +5,7 @@ import com.bitwarden.network.model.DeleteAccountRequestJson
import com.bitwarden.network.model.NetworkResult
import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.UpdateKdfJsonRequest
import com.bitwarden.network.model.VerifyOtpRequestJson
import retrofit2.http.Body
import retrofit2.http.HTTP
@@ -36,6 +37,12 @@ internal interface AuthenticatedAccountsApi {
@POST("/accounts/request-otp")
suspend fun requestOtp(): NetworkResult<Unit>
/**
* Update the KDF settings for the current account.
*/
@POST("/accounts/kdf")
suspend fun updateKdf(@Body body: UpdateKdfJsonRequest): NetworkResult<Unit>
@POST("/accounts/verify-otp")
suspend fun verifyOtp(
@Body body: VerifyOtpRequestJson,

View File

@@ -0,0 +1,22 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the request body used to create the kdf settings.
*/
@Serializable
data class KdfJsonRequest(
@SerialName("KdfType")
val kdfType: KdfTypeJson,
@SerialName("Iterations")
val iterations: Int,
@SerialName("Memory")
val memory: Int?,
@SerialName("Parallelism")
val parallelism: Int?,
)

View File

@@ -0,0 +1,19 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the request body used to authenticate with the master password.
*/
@Serializable
data class MasterPasswordAuthenticationDataJsonRequest(
@SerialName("Kdf")
val kdf: KdfJsonRequest,
@SerialName("MasterPasswordAuthenticationHash")
val masterPasswordAuthenticationHash: String,
@SerialName("Salt")
val salt: String,
)

View File

@@ -0,0 +1,19 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the request body used to unlock with the master password.
*/
@Serializable
data class MasterPasswordUnlockDataJsonRequest(
@SerialName("Kdf")
val kdf: KdfJsonRequest,
@SerialName("MasterKeyWrappedUserKey")
val masterKeyWrappedUserKey: String,
@SerialName("Salt")
val salt: String,
)

View File

@@ -0,0 +1,25 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the request body used to update the user's kdf settings.
*/
@Serializable
data class UpdateKdfJsonRequest(
@SerialName("authenticationData")
val authenticationData: MasterPasswordAuthenticationDataJsonRequest,
@SerialName("key")
val key: String,
@SerialName("masterPasswordHash")
val masterPasswordHash: String,
@SerialName("newMasterPasswordHash")
val newMasterPasswordHash: String,
@SerialName("unlockData")
val unlockData: MasterPasswordUnlockDataJsonRequest,
)

View File

@@ -8,6 +8,7 @@ import com.bitwarden.network.model.ResendEmailRequestJson
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.UpdateKdfJsonRequest
/**
* Provides an API for querying accounts endpoints.
@@ -109,4 +110,9 @@ interface AccountsService {
accessToken: String,
masterKey: String,
): Result<Unit>
/**
* Update the KDF settings for the current account.
*/
suspend fun updateKdf(body: UpdateKdfJsonRequest): Result<Unit>
}

View File

@@ -16,6 +16,7 @@ import com.bitwarden.network.model.ResendEmailRequestJson
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.UpdateKdfJsonRequest
import com.bitwarden.network.model.VerifyOtpRequestJson
import com.bitwarden.network.model.toBitwardenError
import com.bitwarden.network.util.HEADER_VALUE_BEARER_PREFIX
@@ -183,4 +184,9 @@ internal class AccountsServiceImpl(
body = KeyConnectorMasterKeyRequestJson(masterKey = masterKey),
)
.toResult()
override suspend fun updateKdf(body: UpdateKdfJsonRequest): Result<Unit> =
authenticatedAccountsApi
.updateKdf(body)
.toResult()
}

View File

@@ -6,15 +6,19 @@ import com.bitwarden.network.api.AuthenticatedKeyConnectorApi
import com.bitwarden.network.api.UnauthenticatedAccountsApi
import com.bitwarden.network.api.UnauthenticatedKeyConnectorApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.KdfJsonRequest
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
import com.bitwarden.network.model.MasterPasswordAuthenticationDataJsonRequest
import com.bitwarden.network.model.MasterPasswordUnlockDataJsonRequest
import com.bitwarden.network.model.PasswordHintResponseJson
import com.bitwarden.network.model.RegisterRequestJson
import com.bitwarden.network.model.ResendEmailRequestJson
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.UpdateKdfJsonRequest
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
@@ -264,4 +268,50 @@ class AccountsServiceTest : BaseServiceTest() {
)
assertTrue(result.isSuccess)
}
@Test
fun `updateKdf success should return Success`() = runTest {
val response = MockResponse().setResponseCode(200)
server.enqueue(response)
val result = service.updateKdf(body = UPDATE_KDF_REQUEST)
assertTrue(result.isSuccess)
}
@Test
fun `updateKdf failure should return Failure`() = runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
val result = service.updateKdf(body = UPDATE_KDF_REQUEST)
assertTrue(result.isFailure)
}
private val UPDATE_KDF_REQUEST = UpdateKdfJsonRequest(
authenticationData = MasterPasswordAuthenticationDataJsonRequest(
kdf = KdfJsonRequest(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = 7,
memory = 1,
parallelism = 2,
),
masterPasswordAuthenticationHash = "mockMasterPasswordHash",
salt = "mockSalt",
),
key = "mockKey",
masterPasswordHash = "mockMasterPasswordHash",
newMasterPasswordHash = "mockNewMasterPasswordHash",
unlockData = MasterPasswordUnlockDataJsonRequest(
kdf = KdfJsonRequest(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = 7,
memory = 1,
parallelism = 2,
),
masterKeyWrappedUserKey = "mockMasterPasswordKey",
salt = "mockSalt",
),
)
}

View File

@@ -1084,4 +1084,8 @@ Do you want to switch to this account?</string>
<string name="default_uri_match_detection_description_advanced_options">URI match detection controls how Bitwarden identifies autofill suggestions.\n<annotation emphasis="bold">Warning:</annotation> “Starts with” is an advanced option with increased risk of exposing credentials.</string>
<string name="advanced_option_with_increased_risk_of_exposing_credentials">“Starts with” is an advanced option with increased risk of exposing credentials.</string>
<string name="advanced_option_increased_risk_exposing_credentials_used_incorrectly">“Regular expression” is an advanced option with increased risk of exposing credentials if used incorrectly.</string>
<string name="later">Later</string>
<string name="encryption_settings_updated">Encryption settings updated</string>
<string name="update_your_encryption_settings">Update your encryption settings</string>
<string name="the_new_recommended_encryption_settings_will_improve_your_account_security_desc_long">The new recommended encryption settings will improve your account security. Enter your master password to update now.</string>
</resources>