mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 09:56:45 -06:00
[PM-23278] Upgrade user KDF settings to minimums (#5955)
Co-authored-by: David Perez <david@livefront.com>
This commit is contained in:
parent
44c373a354
commit
d98ff6478f
@ -1,6 +1,7 @@
|
|||||||
package com.x8bit.bitwarden.data.auth.datasource.sdk.util
|
package com.x8bit.bitwarden.data.auth.datasource.sdk.util
|
||||||
|
|
||||||
import com.bitwarden.crypto.Kdf
|
import com.bitwarden.crypto.Kdf
|
||||||
|
import com.bitwarden.network.model.KdfJson
|
||||||
import com.bitwarden.network.model.KdfTypeJson
|
import com.bitwarden.network.model.KdfTypeJson
|
||||||
import com.bitwarden.network.model.KdfTypeJson.ARGON2_ID
|
import com.bitwarden.network.model.KdfTypeJson.ARGON2_ID
|
||||||
import com.bitwarden.network.model.KdfTypeJson.PBKDF2_SHA256
|
import com.bitwarden.network.model.KdfTypeJson.PBKDF2_SHA256
|
||||||
@ -13,3 +14,23 @@ fun Kdf.toKdfTypeJson(): KdfTypeJson =
|
|||||||
is Kdf.Argon2id -> ARGON2_ID
|
is Kdf.Argon2id -> ARGON2_ID
|
||||||
is Kdf.Pbkdf2 -> PBKDF2_SHA256
|
is Kdf.Pbkdf2 -> PBKDF2_SHA256
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a [Kdf] to [KdfJson]
|
||||||
|
*/
|
||||||
|
fun Kdf.toKdfRequestModel(): KdfJson =
|
||||||
|
when (this) {
|
||||||
|
is Kdf.Argon2id -> KdfJson(
|
||||||
|
kdfType = toKdfTypeJson(),
|
||||||
|
iterations = iterations.toInt(),
|
||||||
|
memory = memory.toInt(),
|
||||||
|
parallelism = parallelism.toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
is Kdf.Pbkdf2 -> KdfJson(
|
||||||
|
kdfType = toKdfTypeJson(),
|
||||||
|
iterations = iterations.toInt(),
|
||||||
|
memory = null,
|
||||||
|
parallelism = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.x8bit.bitwarden.data.auth.manager
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface to manage the user KDF settings.
|
||||||
|
*/
|
||||||
|
interface KdfManager {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if user's current KDF settings are below the minimums and needs update
|
||||||
|
*/
|
||||||
|
fun needsKdfUpdateToMinimums(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the user's KDF settings if below the minimums
|
||||||
|
*/
|
||||||
|
suspend fun updateKdfToMinimumsIfNeeded(password: String): UpdateKdfMinimumsResult
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
package com.x8bit.bitwarden.data.auth.manager
|
||||||
|
|
||||||
|
import com.bitwarden.core.UpdateKdfResponse
|
||||||
|
import com.bitwarden.core.data.manager.model.FlagKey
|
||||||
|
import com.bitwarden.core.data.util.flatMap
|
||||||
|
import com.bitwarden.crypto.Kdf
|
||||||
|
import com.bitwarden.network.model.KdfTypeJson
|
||||||
|
import com.bitwarden.network.model.MasterPasswordAuthenticationDataJson
|
||||||
|
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
|
||||||
|
import com.bitwarden.network.model.UpdateKdfJsonRequest
|
||||||
|
import com.bitwarden.network.service.AccountsService
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJsonKdfUpdatedMinimums
|
||||||
|
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
|
||||||
|
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||||
|
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||||
|
import kotlin.collections.get
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of [KdfManager].
|
||||||
|
*/
|
||||||
|
class KdfManagerImpl(
|
||||||
|
private val authDiskSource: AuthDiskSource,
|
||||||
|
private val vaultSdkSource: VaultSdkSource,
|
||||||
|
private val accountsService: AccountsService,
|
||||||
|
private val featureFlagManager: FeatureFlagManager,
|
||||||
|
) : KdfManager {
|
||||||
|
|
||||||
|
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
|
||||||
|
|
||||||
|
override fun needsKdfUpdateToMinimums(): Boolean {
|
||||||
|
if (!featureFlagManager.getFeatureFlag(FlagKey.ForceUpdateKdfSettings)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val account = authDiskSource
|
||||||
|
.userState
|
||||||
|
?.accounts
|
||||||
|
?.get(activeUserId)
|
||||||
|
?: return false
|
||||||
|
|
||||||
|
if (account.profile.userDecryptionOptions != null &&
|
||||||
|
!account.profile.userDecryptionOptions.hasMasterPassword
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
|
||||||
|
if (!needsKdfUpdateToMinimums()) {
|
||||||
|
return UpdateKdfMinimumsResult.Success
|
||||||
|
}
|
||||||
|
return vaultSdkSource
|
||||||
|
.makeUpdateKdf(
|
||||||
|
userId = userId,
|
||||||
|
password = password,
|
||||||
|
kdf = Kdf.Pbkdf2(iterations = DEFAULT_PBKDF2_ITERATIONS.toUInt()),
|
||||||
|
)
|
||||||
|
.flatMap {
|
||||||
|
accountsService.updateKdf(createUpdateKdfRequest(it))
|
||||||
|
}
|
||||||
|
.fold(
|
||||||
|
onSuccess = {
|
||||||
|
authDiskSource.userState = authDiskSource.userState
|
||||||
|
?.toUserStateJsonKdfUpdatedMinimums()
|
||||||
|
UpdateKdfMinimumsResult.Success
|
||||||
|
},
|
||||||
|
onFailure = { UpdateKdfMinimumsResult.Error(error = it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createUpdateKdfRequest(response: UpdateKdfResponse): UpdateKdfJsonRequest {
|
||||||
|
val authData = response.masterPasswordAuthenticationData
|
||||||
|
val oldAuthData = response.oldMasterPasswordAuthenticationData
|
||||||
|
val unlockData = response.masterPasswordUnlockData
|
||||||
|
|
||||||
|
return UpdateKdfJsonRequest(
|
||||||
|
authenticationData = MasterPasswordAuthenticationDataJson(
|
||||||
|
kdf = authData.kdf.toKdfRequestModel(),
|
||||||
|
masterPasswordAuthenticationHash = authData.masterPasswordAuthenticationHash,
|
||||||
|
salt = authData.salt,
|
||||||
|
),
|
||||||
|
key = unlockData.masterKeyWrappedUserKey,
|
||||||
|
masterPasswordHash = oldAuthData.masterPasswordAuthenticationHash,
|
||||||
|
newMasterPasswordHash = authData.masterPasswordAuthenticationHash,
|
||||||
|
unlockData = MasterPasswordUnlockDataJson(
|
||||||
|
kdf = unlockData.kdf.toKdfRequestModel(),
|
||||||
|
masterKeyWrappedUserKey = unlockData.masterKeyWrappedUserKey,
|
||||||
|
salt = unlockData.salt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,8 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
|
|||||||
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManagerImpl
|
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManagerImpl
|
||||||
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager
|
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManagerImpl
|
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManagerImpl
|
||||||
|
import com.x8bit.bitwarden.data.auth.manager.KdfManager
|
||||||
|
import com.x8bit.bitwarden.data.auth.manager.KdfManagerImpl
|
||||||
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
|
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManagerImpl
|
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManagerImpl
|
||||||
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
||||||
@ -25,6 +27,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
|||||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManagerImpl
|
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManagerImpl
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
|
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||||
|
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||||
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
|
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
|
||||||
import com.x8bit.bitwarden.data.platform.manager.PushManager
|
import com.x8bit.bitwarden.data.platform.manager.PushManager
|
||||||
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
|
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
|
||||||
@ -143,4 +146,18 @@ object AuthManagerModule {
|
|||||||
fun providesAuthTokenManager(
|
fun providesAuthTokenManager(
|
||||||
authDiskSource: AuthDiskSource,
|
authDiskSource: AuthDiskSource,
|
||||||
): AuthTokenManager = AuthTokenManagerImpl(authDiskSource = authDiskSource)
|
): AuthTokenManager = AuthTokenManagerImpl(authDiskSource = authDiskSource)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providesKdfManager(
|
||||||
|
authDiskSource: AuthDiskSource,
|
||||||
|
vaultSdkSource: VaultSdkSource,
|
||||||
|
accountsService: AccountsService,
|
||||||
|
featureFlagManager: FeatureFlagManager,
|
||||||
|
): KdfManager = KdfManagerImpl(
|
||||||
|
authDiskSource = authDiskSource,
|
||||||
|
vaultSdkSource = vaultSdkSource,
|
||||||
|
accountsService = accountsService,
|
||||||
|
featureFlagManager = featureFlagManager,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import com.bitwarden.network.model.TwoFactorDataModel
|
|||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||||
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
|
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
|
||||||
|
import com.x8bit.bitwarden.data.auth.manager.KdfManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
|
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
|
||||||
@ -44,7 +45,11 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
* Provides an API for observing an modifying authentication state.
|
* Provides an API for observing an modifying authentication state.
|
||||||
*/
|
*/
|
||||||
@Suppress("TooManyFunctions")
|
@Suppress("TooManyFunctions")
|
||||||
interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateManager {
|
interface AuthRepository :
|
||||||
|
AuthenticatorProvider,
|
||||||
|
AuthRequestManager,
|
||||||
|
KdfManager,
|
||||||
|
UserStateManager {
|
||||||
/**
|
/**
|
||||||
* Models the current auth state.
|
* Models the current auth state.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -53,6 +53,7 @@ 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.toInt
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
|
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.AuthRequestManager
|
||||||
|
import com.x8bit.bitwarden.data.auth.manager.KdfManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
|
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||||
@ -79,6 +80,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.SendVerificationEmailResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
|
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.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.ValidatePasswordResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
||||||
@ -129,6 +131,7 @@ import kotlinx.coroutines.flow.merge
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import timber.log.Timber
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@ -158,11 +161,13 @@ class AuthRepositoryImpl(
|
|||||||
private val userLogoutManager: UserLogoutManager,
|
private val userLogoutManager: UserLogoutManager,
|
||||||
private val policyManager: PolicyManager,
|
private val policyManager: PolicyManager,
|
||||||
private val userStateManager: UserStateManager,
|
private val userStateManager: UserStateManager,
|
||||||
|
private val kdfManager: KdfManager,
|
||||||
logsManager: LogsManager,
|
logsManager: LogsManager,
|
||||||
pushManager: PushManager,
|
pushManager: PushManager,
|
||||||
dispatcherManager: DispatcherManager,
|
dispatcherManager: DispatcherManager,
|
||||||
) : AuthRepository,
|
) : AuthRepository,
|
||||||
AuthRequestManager by authRequestManager,
|
AuthRequestManager by authRequestManager,
|
||||||
|
KdfManager by kdfManager,
|
||||||
UserStateManager by userStateManager {
|
UserStateManager by userStateManager {
|
||||||
/**
|
/**
|
||||||
* A scope intended for use when simply collecting multiple flows in order to combine them. The
|
* A scope intended for use when simply collecting multiple flows in order to combine them. The
|
||||||
@ -1681,6 +1686,16 @@ class AuthRepositoryImpl(
|
|||||||
settingsRepository.hasUserLoggedInOrCreatedAccount = true
|
settingsRepository.hasUserLoggedInOrCreatedAccount = true
|
||||||
|
|
||||||
authDiskSource.userState = userStateJson
|
authDiskSource.userState = userStateJson
|
||||||
|
password?.let {
|
||||||
|
// Automatically update kdf to minimums after password unlock and userState update
|
||||||
|
kdfManager
|
||||||
|
.updateKdfToMinimumsIfNeeded(password = password)
|
||||||
|
.also { result ->
|
||||||
|
if (result is UpdateKdfMinimumsResult.Error) {
|
||||||
|
Timber.e(result.error, message = "Failed to silent update KDF settings.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
loginResponse.key?.let {
|
loginResponse.key?.let {
|
||||||
// Only set the value if it's present, since we may have set it already
|
// Only set the value if it's present, since we may have set it already
|
||||||
// when we completed the pending admin auth request.
|
// when we completed the pending admin auth request.
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import com.bitwarden.network.service.OrganizationService
|
|||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
||||||
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
|
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
|
||||||
|
import com.x8bit.bitwarden.data.auth.manager.KdfManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
|
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||||
@ -67,6 +68,7 @@ object AuthRepositoryModule {
|
|||||||
policyManager: PolicyManager,
|
policyManager: PolicyManager,
|
||||||
logsManager: LogsManager,
|
logsManager: LogsManager,
|
||||||
userStateManager: UserStateManager,
|
userStateManager: UserStateManager,
|
||||||
|
kdfManager: KdfManager,
|
||||||
): AuthRepository = AuthRepositoryImpl(
|
): AuthRepository = AuthRepositoryImpl(
|
||||||
clock = clock,
|
clock = clock,
|
||||||
accountsService = accountsService,
|
accountsService = accountsService,
|
||||||
@ -91,6 +93,7 @@ object AuthRepositoryModule {
|
|||||||
policyManager = policyManager,
|
policyManager = policyManager,
|
||||||
logsManager = logsManager,
|
logsManager = logsManager,
|
||||||
userStateManager = userStateManager,
|
userStateManager = userStateManager,
|
||||||
|
kdfManager = kdfManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package com.x8bit.bitwarden.data.auth.repository.util
|
package com.x8bit.bitwarden.data.auth.repository.util
|
||||||
|
|
||||||
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
|
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
|
||||||
|
import com.bitwarden.network.model.KdfTypeJson
|
||||||
import com.bitwarden.network.model.OrganizationType
|
import com.bitwarden.network.model.OrganizationType
|
||||||
import com.bitwarden.network.model.SyncResponseJson
|
import com.bitwarden.network.model.SyncResponseJson
|
||||||
import com.bitwarden.network.model.UserDecryptionOptionsJson
|
import com.bitwarden.network.model.UserDecryptionOptionsJson
|
||||||
@ -12,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
|
|||||||
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
|
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||||
|
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
|
||||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
|
||||||
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
|
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
|
||||||
@ -123,6 +125,30 @@ fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the [UserStateJson] KDF settings to minimum requirements.
|
||||||
|
*/
|
||||||
|
fun UserStateJson.toUserStateJsonKdfUpdatedMinimums(): UserStateJson {
|
||||||
|
val account = this.activeAccount
|
||||||
|
val profile = account.profile
|
||||||
|
val updatedProfile = profile
|
||||||
|
.copy(
|
||||||
|
kdfType = KdfTypeJson.PBKDF2_SHA256,
|
||||||
|
kdfIterations = DEFAULT_PBKDF2_ITERATIONS,
|
||||||
|
kdfMemory = null,
|
||||||
|
kdfParallelism = null,
|
||||||
|
)
|
||||||
|
val updatedAccount = account.copy(profile = updatedProfile)
|
||||||
|
return this
|
||||||
|
.copy(
|
||||||
|
accounts = accounts
|
||||||
|
.toMutableMap()
|
||||||
|
.apply {
|
||||||
|
replace(activeUserId, updatedAccount)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the given [UserStateJson] to a [UserState] using the given [vaultState].
|
* Converts the given [UserStateJson] to a [UserState] using the given [vaultState].
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import com.bitwarden.core.DerivePinKeyResponse
|
|||||||
import com.bitwarden.core.InitOrgCryptoRequest
|
import com.bitwarden.core.InitOrgCryptoRequest
|
||||||
import com.bitwarden.core.InitUserCryptoMethod
|
import com.bitwarden.core.InitUserCryptoMethod
|
||||||
import com.bitwarden.core.InitUserCryptoRequest
|
import com.bitwarden.core.InitUserCryptoRequest
|
||||||
|
import com.bitwarden.core.UpdateKdfResponse
|
||||||
import com.bitwarden.core.UpdatePasswordResponse
|
import com.bitwarden.core.UpdatePasswordResponse
|
||||||
import com.bitwarden.crypto.Kdf
|
import com.bitwarden.crypto.Kdf
|
||||||
import com.bitwarden.crypto.TrustDeviceResponse
|
import com.bitwarden.crypto.TrustDeviceResponse
|
||||||
@ -491,4 +492,13 @@ interface VaultSdkSource {
|
|||||||
fido2CredentialStore: Fido2CredentialStore,
|
fido2CredentialStore: Fido2CredentialStore,
|
||||||
relyingPartyId: String,
|
relyingPartyId: String,
|
||||||
): Result<List<Fido2CredentialAutofillView>>
|
): 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>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import com.bitwarden.core.DeriveKeyConnectorRequest
|
|||||||
import com.bitwarden.core.DerivePinKeyResponse
|
import com.bitwarden.core.DerivePinKeyResponse
|
||||||
import com.bitwarden.core.InitOrgCryptoRequest
|
import com.bitwarden.core.InitOrgCryptoRequest
|
||||||
import com.bitwarden.core.InitUserCryptoRequest
|
import com.bitwarden.core.InitUserCryptoRequest
|
||||||
|
import com.bitwarden.core.UpdateKdfResponse
|
||||||
import com.bitwarden.core.UpdatePasswordResponse
|
import com.bitwarden.core.UpdatePasswordResponse
|
||||||
import com.bitwarden.crypto.Kdf
|
import com.bitwarden.crypto.Kdf
|
||||||
import com.bitwarden.crypto.TrustDeviceResponse
|
import com.bitwarden.crypto.TrustDeviceResponse
|
||||||
@ -608,4 +609,14 @@ class VaultSdkSourceImpl(
|
|||||||
)
|
)
|
||||||
.silentlyDiscoverCredentials(relyingPartyId)
|
.silentlyDiscoverCredentials(relyingPartyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun makeUpdateKdf(
|
||||||
|
userId: String,
|
||||||
|
password: String,
|
||||||
|
kdf: Kdf,
|
||||||
|
): Result<UpdateKdfResponse> = runCatchingWithLogs {
|
||||||
|
getClient(userId = userId)
|
||||||
|
.crypto()
|
||||||
|
.makeUpdateKdf(password = password, kdf = kdf)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,9 +17,11 @@ import com.bitwarden.crypto.Kdf
|
|||||||
import com.bitwarden.data.manager.DispatcherManager
|
import com.bitwarden.data.manager.DispatcherManager
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
||||||
|
import com.x8bit.bitwarden.data.auth.manager.KdfManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
||||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||||
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
|
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
|
||||||
@ -64,6 +66,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
@ -86,6 +89,7 @@ class VaultLockManagerImpl(
|
|||||||
private val appStateManager: AppStateManager,
|
private val appStateManager: AppStateManager,
|
||||||
private val userLogoutManager: UserLogoutManager,
|
private val userLogoutManager: UserLogoutManager,
|
||||||
private val trustedDeviceManager: TrustedDeviceManager,
|
private val trustedDeviceManager: TrustedDeviceManager,
|
||||||
|
private val kdfManager: KdfManager,
|
||||||
dispatcherManager: DispatcherManager,
|
dispatcherManager: DispatcherManager,
|
||||||
context: Context,
|
context: Context,
|
||||||
) : VaultLockManager {
|
) : VaultLockManager {
|
||||||
@ -230,13 +234,12 @@ class VaultLockManagerImpl(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.also {
|
|
||||||
if (it is VaultUnlockResult.Success) {
|
if (it is VaultUnlockResult.Success) {
|
||||||
clearInvalidUnlockCount(userId = userId)
|
clearInvalidUnlockCount(userId = userId)
|
||||||
trustedDeviceManager
|
trustedDeviceManager
|
||||||
.trustThisDeviceIfNecessary(userId = userId)
|
.trustThisDeviceIfNecessary(userId = userId)
|
||||||
.also { setVaultToUnlocked(userId = userId) }
|
updateKdfIfNeeded(initUserCryptoMethod)
|
||||||
|
setVaultToUnlocked(userId = userId)
|
||||||
} else {
|
} else {
|
||||||
incrementInvalidUnlockCount(userId = userId)
|
incrementInvalidUnlockCount(userId = userId)
|
||||||
}
|
}
|
||||||
@ -673,6 +676,20 @@ class VaultLockManagerImpl(
|
|||||||
return (accounts.find { it.userId == userId }?.isLoggedIn) == false
|
return (accounts.find { it.userId == userId }?.isLoggedIn) == false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun updateKdfIfNeeded(initUserCryptoMethod: InitUserCryptoMethod) {
|
||||||
|
if (initUserCryptoMethod is InitUserCryptoMethod.Password) {
|
||||||
|
kdfManager
|
||||||
|
.updateKdfToMinimumsIfNeeded(
|
||||||
|
password = initUserCryptoMethod.password,
|
||||||
|
)
|
||||||
|
.also { result ->
|
||||||
|
if (result is UpdateKdfMinimumsResult.Error) {
|
||||||
|
Timber.e(result.error, message = "Failed to silent update KDF settings.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom [BroadcastReceiver] that listens for when the screen is powered on and restarts the
|
* A custom [BroadcastReceiver] that listens for when the screen is powered on and restarts the
|
||||||
* vault timeout jobs to ensure they complete at the correct time.
|
* vault timeout jobs to ensure they complete at the correct time.
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import com.bitwarden.network.service.SendsService
|
|||||||
import com.bitwarden.network.service.SyncService
|
import com.bitwarden.network.service.SyncService
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
||||||
|
import com.x8bit.bitwarden.data.auth.manager.KdfManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||||
@ -142,6 +143,7 @@ object VaultManagerModule {
|
|||||||
userLogoutManager: UserLogoutManager,
|
userLogoutManager: UserLogoutManager,
|
||||||
dispatcherManager: DispatcherManager,
|
dispatcherManager: DispatcherManager,
|
||||||
trustedDeviceManager: TrustedDeviceManager,
|
trustedDeviceManager: TrustedDeviceManager,
|
||||||
|
kdfManager: KdfManager,
|
||||||
): VaultLockManager =
|
): VaultLockManager =
|
||||||
VaultLockManagerImpl(
|
VaultLockManagerImpl(
|
||||||
context = context,
|
context = context,
|
||||||
@ -155,6 +157,7 @@ object VaultManagerModule {
|
|||||||
userLogoutManager = userLogoutManager,
|
userLogoutManager = userLogoutManager,
|
||||||
dispatcherManager = dispatcherManager,
|
dispatcherManager = dispatcherManager,
|
||||||
trustedDeviceManager = trustedDeviceManager,
|
trustedDeviceManager = trustedDeviceManager,
|
||||||
|
kdfManager = kdfManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import androidx.compose.ui.semantics.semantics
|
|||||||
import androidx.compose.ui.semantics.testTag
|
import androidx.compose.ui.semantics.testTag
|
||||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||||
import androidx.compose.ui.unit.dp
|
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.button.BitwardenTextButton
|
||||||
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
|
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
|
||||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||||
@ -28,12 +29,20 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
|
|||||||
/**
|
/**
|
||||||
* Represents a Bitwarden-styled dialog for entering your master password.
|
* 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 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
|
* @param onDismissRequest called when the user attempts to dismiss the dialog (for example by
|
||||||
* tapping outside of it).
|
* tapping outside of it).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BitwardenMasterPasswordDialog(
|
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,
|
onConfirmClick: (masterPassword: String) -> Unit,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@ -42,14 +51,14 @@ fun BitwardenMasterPasswordDialog(
|
|||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
BitwardenTextButton(
|
BitwardenTextButton(
|
||||||
label = stringResource(id = BitwardenString.cancel),
|
label = dismissButtonText,
|
||||||
onClick = onDismissRequest,
|
onClick = onDismissRequest,
|
||||||
modifier = Modifier.testTag("DismissAlertButton"),
|
modifier = Modifier.testTag("DismissAlertButton"),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
BitwardenTextButton(
|
BitwardenFilledButton(
|
||||||
label = stringResource(id = BitwardenString.submit),
|
label = confirmButtonText,
|
||||||
isEnabled = masterPassword.isNotEmpty(),
|
isEnabled = masterPassword.isNotEmpty(),
|
||||||
onClick = { onConfirmClick(masterPassword) },
|
onClick = { onConfirmClick(masterPassword) },
|
||||||
modifier = Modifier.testTag("AcceptAlertButton"),
|
modifier = Modifier.testTag("AcceptAlertButton"),
|
||||||
@ -57,7 +66,7 @@ fun BitwardenMasterPasswordDialog(
|
|||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = BitwardenString.password_confirmation),
|
text = title,
|
||||||
style = BitwardenTheme.typography.headlineSmall,
|
style = BitwardenTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.testTag("AlertTitleText"),
|
modifier = Modifier.testTag("AlertTitleText"),
|
||||||
)
|
)
|
||||||
@ -65,7 +74,7 @@ fun BitwardenMasterPasswordDialog(
|
|||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = BitwardenString.password_confirmation_desc),
|
text = message,
|
||||||
style = BitwardenTheme.typography.bodyMedium,
|
style = BitwardenTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.testTag("AlertContentText"),
|
modifier = Modifier.testTag("AlertContentText"),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -59,6 +59,7 @@ import com.bitwarden.ui.platform.manager.IntentManager
|
|||||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||||
import com.bitwarden.ui.platform.resource.BitwardenPlurals
|
import com.bitwarden.ui.platform.resource.BitwardenPlurals
|
||||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
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.LocalAppReviewManager
|
||||||
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
|
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
|
||||||
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
|
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
|
||||||
@ -458,6 +459,18 @@ 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import com.bitwarden.vault.DecryptCipherListResult
|
|||||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
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.LogoutReason
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
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.UserState
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
|
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
|
||||||
@ -284,6 +285,10 @@ class VaultViewModel @Inject constructor(
|
|||||||
handleShareAllCipherDecryptionErrorsClick()
|
handleShareAllCipherDecryptionErrorsClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is VaultAction.KdfUpdatePasswordRepromptSubmit -> {
|
||||||
|
handleKdfUpdatePasswordRepromptSubmit(action)
|
||||||
|
}
|
||||||
|
|
||||||
VaultAction.EnableThirdPartyAutofillClick -> handleEnableThirdPartyAutofillClick()
|
VaultAction.EnableThirdPartyAutofillClick -> handleEnableThirdPartyAutofillClick()
|
||||||
VaultAction.DismissThirdPartyAutofillDialogClick -> {
|
VaultAction.DismissThirdPartyAutofillDialogClick -> {
|
||||||
handleDismissThirdPartyAutofillDialogClick()
|
handleDismissThirdPartyAutofillDialogClick()
|
||||||
@ -796,12 +801,53 @@ class VaultViewModel @Inject constructor(
|
|||||||
handleDecryptionErrorReceive(action)
|
handleDecryptionErrorReceive(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is VaultAction.Internal.UpdatedKdfToMinimumsReceived -> {
|
||||||
|
handleUpdatedKdfToMinimumsReceived(action)
|
||||||
|
}
|
||||||
|
|
||||||
is VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive -> {
|
is VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive -> {
|
||||||
handleCredentialExchangeProtocolExportFlagUpdateReceive(action)
|
handleCredentialExchangeProtocolExportFlagUpdateReceive(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleUpdatedKdfToMinimumsReceived(
|
||||||
|
action: VaultAction.Internal.UpdatedKdfToMinimumsReceived,
|
||||||
|
) {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(dialog = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val result = action.result) {
|
||||||
|
UpdateKdfMinimumsResult.ActiveAccountNotFound -> {
|
||||||
|
showGenericError(
|
||||||
|
message = BitwardenString.kdf_update_failed_active_account_not_found.asText(),
|
||||||
|
)
|
||||||
|
Timber.e(message = "Failed to update kdf to minimums: Active account not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
is UpdateKdfMinimumsResult.Error -> {
|
||||||
|
showGenericError(
|
||||||
|
message = BitwardenString
|
||||||
|
.an_error_occurred_while_trying_to_update_your_kdf_settings
|
||||||
|
.asText(),
|
||||||
|
error = result.error,
|
||||||
|
)
|
||||||
|
Timber.e(result.error, message = "Failed to update kdf to minimums.")
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateKdfMinimumsResult.Success -> {
|
||||||
|
sendEvent(
|
||||||
|
event = VaultEvent.ShowSnackbar(
|
||||||
|
data = BitwardenSnackbarData(
|
||||||
|
message = BitwardenString.encryption_settings_updated.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleCredentialExchangeProtocolExportFlagUpdateReceive(
|
private fun handleCredentialExchangeProtocolExportFlagUpdateReceive(
|
||||||
action: VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive,
|
action: VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive,
|
||||||
) {
|
) {
|
||||||
@ -964,22 +1010,15 @@ class VaultViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val shouldShowDecryptionAlert = !state.hasShownDecryptionFailureAlert &&
|
val shouldShowDecryptionAlert = !state.hasShownDecryptionFailureAlert &&
|
||||||
vaultData.data.decryptCipherListResult.failures.isNotEmpty()
|
vaultData.data.decryptCipherListResult.failures.isNotEmpty() &&
|
||||||
|
state.dialog == null
|
||||||
|
|
||||||
updateVaultState(
|
updateVaultState(
|
||||||
vaultData = vaultData.data,
|
vaultData = vaultData.data,
|
||||||
dialog = if (shouldShowDecryptionAlert ||
|
dialog = getDialogVaultLoaded(
|
||||||
state.dialog is VaultState.DialogState.VaultLoadCipherDecryptionError
|
shouldShowDecryptionAlert = shouldShowDecryptionAlert,
|
||||||
) {
|
vaultData = vaultData,
|
||||||
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
|
|
||||||
},
|
|
||||||
hasShownDecryptionFailureAlert = if (shouldShowDecryptionAlert) {
|
hasShownDecryptionFailureAlert = if (shouldShowDecryptionAlert) {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
@ -988,6 +1027,29 @@ class VaultViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getDialogVaultLoaded(
|
||||||
|
shouldShowDecryptionAlert: Boolean,
|
||||||
|
vaultData: DataState.Loaded<VaultData>,
|
||||||
|
): VaultState.DialogState? = if (authRepository.needsKdfUpdateToMinimums()) {
|
||||||
|
VaultState.DialogState.VaultLoadKdfUpdateRequired(
|
||||||
|
title = BitwardenString.update_your_encryption_settings.asText(),
|
||||||
|
message = BitwardenString
|
||||||
|
.the_new_recommended_encryption_settings_will_improve_your_account_desc_long
|
||||||
|
.asText(),
|
||||||
|
)
|
||||||
|
} else if (shouldShowDecryptionAlert ||
|
||||||
|
state.dialog is VaultState.DialogState.VaultLoadCipherDecryptionError
|
||||||
|
) {
|
||||||
|
VaultState.DialogState.VaultLoadCipherDecryptionError(
|
||||||
|
title = BitwardenString.decryption_error.asText(),
|
||||||
|
cipherCount = vaultData.data.decryptCipherListResult.failures.size,
|
||||||
|
)
|
||||||
|
} else if (state.dialog is VaultState.DialogState.ThirdPartyBrowserAutofill) {
|
||||||
|
state.dialog
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateVaultState(
|
private fun updateVaultState(
|
||||||
vaultData: VaultData,
|
vaultData: VaultData,
|
||||||
dialog: VaultState.DialogState? = null,
|
dialog: VaultState.DialogState? = null,
|
||||||
@ -1146,6 +1208,30 @@ class VaultViewModel @Inject constructor(
|
|||||||
|
|
||||||
is GetCipherResult.Success -> result.cipherView
|
is GetCipherResult.Success -> result.cipherView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleKdfUpdatePasswordRepromptSubmit(
|
||||||
|
action: VaultAction.KdfUpdatePasswordRepromptSubmit,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = authRepository.updateKdfToMinimumsIfNeeded(password = action.password)
|
||||||
|
sendAction(action = VaultAction.Internal.UpdatedKdfToMinimumsReceived(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showGenericError(
|
||||||
|
message: Text = BitwardenString.generic_error_message.asText(),
|
||||||
|
error: Throwable? = null,
|
||||||
|
) {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = VaultState.DialogState.Error(
|
||||||
|
title = BitwardenString.an_error_has_occurred.asText(),
|
||||||
|
message = message,
|
||||||
|
error = error,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1531,6 +1617,15 @@ data class VaultState(
|
|||||||
val cipherCount: Int,
|
val cipherCount: Int,
|
||||||
) : DialogState()
|
) : 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].
|
* Represents an error dialog with the given [title] and [message].
|
||||||
*/
|
*/
|
||||||
@ -1784,6 +1879,13 @@ sealed class VaultAction {
|
|||||||
val selectedCipherId: String,
|
val selectedCipherId: String,
|
||||||
) : VaultAction()
|
) : 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.
|
* Click to share all cipher decryption error details.
|
||||||
*/
|
*/
|
||||||
@ -1943,6 +2045,13 @@ sealed class VaultAction {
|
|||||||
val error: Throwable?,
|
val error: Throwable?,
|
||||||
) : Internal()
|
) : Internal()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates that a result for updating the kdf has been received.
|
||||||
|
*/
|
||||||
|
data class UpdatedKdfToMinimumsReceived(
|
||||||
|
val result: UpdateKdfMinimumsResult,
|
||||||
|
) : Internal()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates that the Credential Exchange Protocol export flag has been updated.
|
* Indicates that the Credential Exchange Protocol export flag has been updated.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -46,6 +46,7 @@ data class VaultHandlers(
|
|||||||
val dismissFlightRecorderSnackbar: () -> Unit,
|
val dismissFlightRecorderSnackbar: () -> Unit,
|
||||||
val onShareCipherDecryptionErrorClick: (selectedCipherId: String) -> Unit,
|
val onShareCipherDecryptionErrorClick: (selectedCipherId: String) -> Unit,
|
||||||
val onShareAllCipherDecryptionErrorsClick: () -> Unit,
|
val onShareAllCipherDecryptionErrorsClick: () -> Unit,
|
||||||
|
val onKdfUpdatePasswordRepromptSubmit: (password: String) -> Unit,
|
||||||
val onEnabledThirdPartyAutofillClick: () -> Unit,
|
val onEnabledThirdPartyAutofillClick: () -> Unit,
|
||||||
val onDismissThirdPartyAutofillDialogClick: () -> Unit,
|
val onDismissThirdPartyAutofillDialogClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@ -137,6 +138,9 @@ data class VaultHandlers(
|
|||||||
onDismissThirdPartyAutofillDialogClick = {
|
onDismissThirdPartyAutofillDialogClick = {
|
||||||
viewModel.trySendAction(VaultAction.DismissThirdPartyAutofillDialogClick)
|
viewModel.trySendAction(VaultAction.DismissThirdPartyAutofillDialogClick)
|
||||||
},
|
},
|
||||||
|
onKdfUpdatePasswordRepromptSubmit = {
|
||||||
|
viewModel.trySendAction(VaultAction.KdfUpdatePasswordRepromptSubmit(it))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,422 @@
|
|||||||
|
package com.x8bit.bitwarden.data.auth.manager
|
||||||
|
|
||||||
|
import com.bitwarden.core.MasterPasswordAuthenticationData
|
||||||
|
import com.bitwarden.core.MasterPasswordUnlockData
|
||||||
|
import com.bitwarden.core.UpdateKdfResponse
|
||||||
|
import com.bitwarden.core.data.manager.model.FlagKey
|
||||||
|
import com.bitwarden.core.data.util.asFailure
|
||||||
|
import com.bitwarden.core.data.util.asSuccess
|
||||||
|
import com.bitwarden.crypto.Kdf
|
||||||
|
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
|
||||||
|
import com.bitwarden.network.model.KdfTypeJson
|
||||||
|
import com.bitwarden.network.model.UserDecryptionOptionsJson
|
||||||
|
import com.bitwarden.network.service.AccountsService
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||||
|
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
|
||||||
|
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||||
|
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
class KdfManagerTest {
|
||||||
|
|
||||||
|
private val fakeAuthDiskSource = FakeAuthDiskSource()
|
||||||
|
private val vaultSdkSource: VaultSdkSource = mockk()
|
||||||
|
private val accountsService: AccountsService = mockk()
|
||||||
|
private val featureFlagManager: FeatureFlagManager = mockk {
|
||||||
|
every { getFeatureFlag(FlagKey.ForceUpdateKdfSettings) } returns true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val manager: KdfManager = KdfManagerImpl(
|
||||||
|
authDiskSource = fakeAuthDiskSource,
|
||||||
|
vaultSdkSource = vaultSdkSource,
|
||||||
|
accountsService = accountsService,
|
||||||
|
featureFlagManager = featureFlagManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `needsKdfUpdateToMinimums with no active user should return false`() = runTest {
|
||||||
|
fakeAuthDiskSource.userState = null
|
||||||
|
|
||||||
|
val result = manager.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 = manager.needsKdfUpdateToMinimums()
|
||||||
|
|
||||||
|
assertFalse(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `needsKdfUpdateToMinimums with user decryption options and without password returns false`() =
|
||||||
|
runTest {
|
||||||
|
val account = ACCOUNT_1.copy(
|
||||||
|
profile = ACCOUNT_1.profile.copy(
|
||||||
|
userDecryptionOptions = UserDecryptionOptionsJson(
|
||||||
|
hasMasterPassword = false,
|
||||||
|
keyConnectorUserDecryptionOptions = null,
|
||||||
|
trustedDeviceUserDecryptionOptions = null,
|
||||||
|
masterPasswordUnlock = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy(
|
||||||
|
accounts = mapOf(
|
||||||
|
USER_ID_1 to account,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = manager.needsKdfUpdateToMinimums()
|
||||||
|
assertFalse(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `needsKdfUpdateToMinimums with PBKDF2 below minimum iterations should return true`() =
|
||||||
|
runTest {
|
||||||
|
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
|
||||||
|
|
||||||
|
val result = manager.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 = manager.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 = manager.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 = manager.needsKdfUpdateToMinimums()
|
||||||
|
|
||||||
|
assertFalse(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateKdfToMinimumsIfNeeded with no active user should return ActiveAccountNotFound`() =
|
||||||
|
runTest {
|
||||||
|
fakeAuthDiskSource.userState = null
|
||||||
|
|
||||||
|
val result = manager.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 = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
UpdateKdfMinimumsResult.Success,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `updateKdfToMinimumsIfNeeded with feature flag ForceUpdateKdfSettings to false return Success`() =
|
||||||
|
runTest {
|
||||||
|
every {
|
||||||
|
featureFlagManager.getFeatureFlag(FlagKey.ForceUpdateKdfSettings)
|
||||||
|
} returns false
|
||||||
|
|
||||||
|
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 = 1000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = manager.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 = manager.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 = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD)
|
||||||
|
|
||||||
|
assertEquals(UpdateKdfMinimumsResult.Error(error = error), result)
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
accountsService.updateKdf(any())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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 = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD)
|
||||||
|
|
||||||
|
assertEquals(UpdateKdfMinimumsResult.Success, result)
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
accountsService.updateKdf(any())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `updateKdfToMinimumsIfNeeded with PBKDF2 below minimums should update userState to minimums`() =
|
||||||
|
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 = manager.updateKdfToMinimumsIfNeeded(password = PASSWORD)
|
||||||
|
|
||||||
|
assertEquals(UpdateKdfMinimumsResult.Success, result)
|
||||||
|
|
||||||
|
// Verify userState was updated with minimum KDF values
|
||||||
|
val updatedUserState = fakeAuthDiskSource.userState
|
||||||
|
val updatedProfile = updatedUserState?.accounts?.get(USER_ID_2)?.profile
|
||||||
|
assertEquals(KdfTypeJson.PBKDF2_SHA256, updatedProfile?.kdfType)
|
||||||
|
assertEquals(600000, updatedProfile?.kdfIterations)
|
||||||
|
assertNull(updatedProfile?.kdfMemory)
|
||||||
|
assertNull(updatedProfile?.kdfParallelism)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val EMAIL = "test@bitwarden.com"
|
||||||
|
private const val EMAIL_2 = "test2@bitwarden.com"
|
||||||
|
private const val PASSWORD = "password"
|
||||||
|
private const val USER_ID_1 = "2a135b23-e1fb-42c9-bec3-573857bc8181"
|
||||||
|
private const val USER_ID_2 = "b9d32ec0-6497-4582-9798-b350f53bfa02"
|
||||||
|
private val PROFILE_1 = AccountJson.Profile(
|
||||||
|
userId = USER_ID_1,
|
||||||
|
email = EMAIL,
|
||||||
|
isEmailVerified = true,
|
||||||
|
name = "Bitwarden Tester",
|
||||||
|
hasPremium = false,
|
||||||
|
stamp = null,
|
||||||
|
organizationId = null,
|
||||||
|
avatarColorHex = null,
|
||||||
|
forcePasswordResetReason = null,
|
||||||
|
kdfType = KdfTypeJson.ARGON2_ID,
|
||||||
|
kdfIterations = 600000,
|
||||||
|
kdfMemory = 16,
|
||||||
|
kdfParallelism = 4,
|
||||||
|
userDecryptionOptions = null,
|
||||||
|
isTwoFactorEnabled = false,
|
||||||
|
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ACCOUNT_1 = AccountJson(
|
||||||
|
profile = PROFILE_1,
|
||||||
|
settings = AccountJson.Settings(
|
||||||
|
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ACCOUNT_2 = AccountJson(
|
||||||
|
profile = AccountJson.Profile(
|
||||||
|
userId = USER_ID_2,
|
||||||
|
email = EMAIL_2,
|
||||||
|
isEmailVerified = true,
|
||||||
|
name = "Bitwarden Tester 2",
|
||||||
|
hasPremium = false,
|
||||||
|
stamp = null,
|
||||||
|
organizationId = null,
|
||||||
|
avatarColorHex = null,
|
||||||
|
forcePasswordResetReason = null,
|
||||||
|
kdfType = KdfTypeJson.PBKDF2_SHA256,
|
||||||
|
kdfIterations = 400000,
|
||||||
|
kdfMemory = null,
|
||||||
|
kdfParallelism = null,
|
||||||
|
userDecryptionOptions = null,
|
||||||
|
isTwoFactorEnabled = true,
|
||||||
|
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
|
||||||
|
),
|
||||||
|
settings = AccountJson.Settings(
|
||||||
|
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_EU,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val SINGLE_USER_STATE_1 = UserStateJson(
|
||||||
|
activeUserId = USER_ID_1,
|
||||||
|
accounts = mapOf(
|
||||||
|
USER_ID_1 to ACCOUNT_1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val SINGLE_USER_STATE_2 = UserStateJson(
|
||||||
|
activeUserId = USER_ID_2,
|
||||||
|
accounts = mapOf(
|
||||||
|
USER_ID_2 to ACCOUNT_2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
)
|
||||||
@ -5,8 +5,11 @@ import com.bitwarden.core.AuthRequestMethod
|
|||||||
import com.bitwarden.core.AuthRequestResponse
|
import com.bitwarden.core.AuthRequestResponse
|
||||||
import com.bitwarden.core.InitUserCryptoMethod
|
import com.bitwarden.core.InitUserCryptoMethod
|
||||||
import com.bitwarden.core.KeyConnectorResponse
|
import com.bitwarden.core.KeyConnectorResponse
|
||||||
|
import com.bitwarden.core.MasterPasswordAuthenticationData
|
||||||
|
import com.bitwarden.core.MasterPasswordUnlockData
|
||||||
import com.bitwarden.core.RegisterKeyResponse
|
import com.bitwarden.core.RegisterKeyResponse
|
||||||
import com.bitwarden.core.RegisterTdeKeyResponse
|
import com.bitwarden.core.RegisterTdeKeyResponse
|
||||||
|
import com.bitwarden.core.UpdateKdfResponse
|
||||||
import com.bitwarden.core.UpdatePasswordResponse
|
import com.bitwarden.core.UpdatePasswordResponse
|
||||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||||
import com.bitwarden.core.data.util.asFailure
|
import com.bitwarden.core.data.util.asFailure
|
||||||
@ -74,6 +77,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL
|
|||||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3
|
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_3
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4
|
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_4
|
||||||
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
|
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
|
||||||
|
import com.x8bit.bitwarden.data.auth.manager.KdfManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
|
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||||
@ -100,6 +104,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.SendVerificationEmailResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
|
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.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.ValidatePasswordResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
||||||
@ -266,7 +271,12 @@ class AuthRepositoryTest {
|
|||||||
val blockSlot = slot<suspend () -> LoginResult>()
|
val blockSlot = slot<suspend () -> LoginResult>()
|
||||||
coEvery { userStateTransaction(capture(blockSlot)) } coAnswers { blockSlot.captured() }
|
coEvery { userStateTransaction(capture(blockSlot)) } coAnswers { blockSlot.captured() }
|
||||||
}
|
}
|
||||||
|
private val kdfManager: KdfManager = mockk {
|
||||||
|
every { needsKdfUpdateToMinimums() } returns false
|
||||||
|
coEvery {
|
||||||
|
updateKdfToMinimumsIfNeeded(password = any())
|
||||||
|
} returns UpdateKdfMinimumsResult.Success
|
||||||
|
}
|
||||||
private val repository: AuthRepository = AuthRepositoryImpl(
|
private val repository: AuthRepository = AuthRepositoryImpl(
|
||||||
clock = FIXED_CLOCK,
|
clock = FIXED_CLOCK,
|
||||||
accountsService = accountsService,
|
accountsService = accountsService,
|
||||||
@ -291,6 +301,7 @@ class AuthRepositoryTest {
|
|||||||
policyManager = policyManager,
|
policyManager = policyManager,
|
||||||
logsManager = logsManager,
|
logsManager = logsManager,
|
||||||
userStateManager = userStateManager,
|
userStateManager = userStateManager,
|
||||||
|
kdfManager = kdfManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@ -2127,6 +2138,18 @@ class AuthRepositoryTest {
|
|||||||
)
|
)
|
||||||
} returns VaultUnlockResult.Success
|
} returns VaultUnlockResult.Success
|
||||||
coEvery { vaultRepository.syncIfNecessary() } just runs
|
coEvery { vaultRepository.syncIfNecessary() } just runs
|
||||||
|
coEvery {
|
||||||
|
vaultSdkSource.makeUpdateKdf(
|
||||||
|
userId = any(),
|
||||||
|
password = any(),
|
||||||
|
kdf = any(),
|
||||||
|
)
|
||||||
|
} returns UPDATE_KDF_RESPONSE.asSuccess()
|
||||||
|
coEvery {
|
||||||
|
accountsService.updateKdf(
|
||||||
|
body = any(),
|
||||||
|
)
|
||||||
|
} returns Unit.asSuccess()
|
||||||
every {
|
every {
|
||||||
GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS.toUserState(
|
GET_TOKEN_WITH_ACCOUNT_KEYS_RESPONSE_SUCCESS.toUserState(
|
||||||
previousUserState = SINGLE_USER_STATE_2,
|
previousUserState = SINGLE_USER_STATE_2,
|
||||||
@ -7208,5 +7231,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",
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
|
|||||||
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
|
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||||
|
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
|
||||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@ -1681,6 +1682,192 @@ class UserStateJsonExtensionsTest {
|
|||||||
originalUserState.toUpdatedUserStateJson(syncResponse),
|
originalUserState.toUpdatedUserStateJson(syncResponse),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toUserStateJsonKdfUpdatedMinimums should update KDF settings to minimum values`() {
|
||||||
|
val originalProfile = AccountJson.Profile(
|
||||||
|
userId = "activeUserId",
|
||||||
|
email = "email",
|
||||||
|
isEmailVerified = true,
|
||||||
|
name = "name",
|
||||||
|
stamp = "stamp",
|
||||||
|
organizationId = null,
|
||||||
|
avatarColorHex = "avatarColorHex",
|
||||||
|
hasPremium = true,
|
||||||
|
forcePasswordResetReason = null,
|
||||||
|
kdfType = KdfTypeJson.ARGON2_ID,
|
||||||
|
kdfIterations = 600000,
|
||||||
|
kdfMemory = 16,
|
||||||
|
kdfParallelism = 4,
|
||||||
|
userDecryptionOptions = null,
|
||||||
|
isTwoFactorEnabled = false,
|
||||||
|
creationDate = ZonedDateTime.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 result = originalUserState.toUserStateJsonKdfUpdatedMinimums()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
UserStateJson(
|
||||||
|
activeUserId = "activeUserId",
|
||||||
|
accounts = mapOf(
|
||||||
|
"activeUserId" to originalAccount.copy(
|
||||||
|
profile = originalProfile.copy(
|
||||||
|
kdfType = KdfTypeJson.PBKDF2_SHA256,
|
||||||
|
kdfIterations = DEFAULT_PBKDF2_ITERATIONS,
|
||||||
|
kdfMemory = null,
|
||||||
|
kdfParallelism = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `toUserStateJsonKdfUpdatedMinimums should preserve other profile data while updating KDF`() {
|
||||||
|
val userDecryptionOptions = UserDecryptionOptionsJson(
|
||||||
|
hasMasterPassword = true,
|
||||||
|
trustedDeviceUserDecryptionOptions = null,
|
||||||
|
keyConnectorUserDecryptionOptions = null,
|
||||||
|
masterPasswordUnlock = null,
|
||||||
|
)
|
||||||
|
val originalProfile = AccountJson.Profile(
|
||||||
|
userId = "activeUserId",
|
||||||
|
email = "test@example.com",
|
||||||
|
isEmailVerified = true,
|
||||||
|
name = "Test User",
|
||||||
|
stamp = "securityStamp",
|
||||||
|
organizationId = "orgId",
|
||||||
|
avatarColorHex = "#FF0000",
|
||||||
|
hasPremium = false,
|
||||||
|
forcePasswordResetReason = null,
|
||||||
|
kdfType = KdfTypeJson.ARGON2_ID,
|
||||||
|
kdfIterations = 100000,
|
||||||
|
kdfMemory = 32,
|
||||||
|
kdfParallelism = 8,
|
||||||
|
userDecryptionOptions = userDecryptionOptions,
|
||||||
|
isTwoFactorEnabled = true,
|
||||||
|
creationDate = ZonedDateTime.parse("2024-01-01T00:00:00.00Z"),
|
||||||
|
)
|
||||||
|
val originalAccount = AccountJson(
|
||||||
|
profile = originalProfile,
|
||||||
|
tokens = null,
|
||||||
|
settings = AccountJson.Settings(
|
||||||
|
environmentUrlData = EnvironmentUrlDataJson(base = "https://vault.bitwarden.com"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val originalUserState = UserStateJson(
|
||||||
|
activeUserId = "activeUserId",
|
||||||
|
accounts = mapOf("activeUserId" to originalAccount),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = originalUserState.toUserStateJsonKdfUpdatedMinimums()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
UserStateJson(
|
||||||
|
activeUserId = "activeUserId",
|
||||||
|
accounts = mapOf(
|
||||||
|
"activeUserId" to originalAccount.copy(
|
||||||
|
profile = originalProfile.copy(
|
||||||
|
kdfType = KdfTypeJson.PBKDF2_SHA256,
|
||||||
|
kdfIterations = DEFAULT_PBKDF2_ITERATIONS,
|
||||||
|
kdfMemory = null,
|
||||||
|
kdfParallelism = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toUserStateJsonKdfUpdatedMinimums should only update active user account`() {
|
||||||
|
val activeProfile = AccountJson.Profile(
|
||||||
|
userId = "activeUserId",
|
||||||
|
email = "active@example.com",
|
||||||
|
isEmailVerified = true,
|
||||||
|
name = "Active User",
|
||||||
|
stamp = null,
|
||||||
|
organizationId = null,
|
||||||
|
avatarColorHex = null,
|
||||||
|
hasPremium = true,
|
||||||
|
forcePasswordResetReason = null,
|
||||||
|
kdfType = KdfTypeJson.ARGON2_ID,
|
||||||
|
kdfIterations = 600000,
|
||||||
|
kdfMemory = 16,
|
||||||
|
kdfParallelism = 4,
|
||||||
|
userDecryptionOptions = null,
|
||||||
|
isTwoFactorEnabled = false,
|
||||||
|
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
|
||||||
|
)
|
||||||
|
val inactiveProfile = AccountJson.Profile(
|
||||||
|
userId = "inactiveUserId",
|
||||||
|
email = "inactive@example.com",
|
||||||
|
isEmailVerified = true,
|
||||||
|
name = "Inactive User",
|
||||||
|
stamp = null,
|
||||||
|
organizationId = null,
|
||||||
|
avatarColorHex = null,
|
||||||
|
hasPremium = false,
|
||||||
|
forcePasswordResetReason = null,
|
||||||
|
kdfType = KdfTypeJson.ARGON2_ID,
|
||||||
|
kdfIterations = 500000,
|
||||||
|
kdfMemory = 8,
|
||||||
|
kdfParallelism = 2,
|
||||||
|
userDecryptionOptions = null,
|
||||||
|
isTwoFactorEnabled = false,
|
||||||
|
creationDate = ZonedDateTime.parse("2024-08-13T01:00:00.00Z"),
|
||||||
|
)
|
||||||
|
val activeAccount = AccountJson(
|
||||||
|
profile = activeProfile,
|
||||||
|
tokens = null,
|
||||||
|
settings = AccountJson.Settings(environmentUrlData = null),
|
||||||
|
)
|
||||||
|
val inactiveAccount = AccountJson(
|
||||||
|
profile = inactiveProfile,
|
||||||
|
tokens = null,
|
||||||
|
settings = AccountJson.Settings(environmentUrlData = null),
|
||||||
|
)
|
||||||
|
val originalUserState = UserStateJson(
|
||||||
|
activeUserId = "activeUserId",
|
||||||
|
accounts = mapOf(
|
||||||
|
"activeUserId" to activeAccount,
|
||||||
|
"inactiveUserId" to inactiveAccount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = originalUserState.toUserStateJsonKdfUpdatedMinimums()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
UserStateJson(
|
||||||
|
activeUserId = "activeUserId",
|
||||||
|
accounts = mapOf(
|
||||||
|
"activeUserId" to activeAccount.copy(
|
||||||
|
profile = activeProfile.copy(
|
||||||
|
kdfType = KdfTypeJson.PBKDF2_SHA256,
|
||||||
|
kdfIterations = DEFAULT_PBKDF2_ITERATIONS,
|
||||||
|
kdfMemory = null,
|
||||||
|
kdfParallelism = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"inactiveUserId" to inactiveAccount, // Should remain unchanged
|
||||||
|
),
|
||||||
|
),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val MOCK_MASTER_PASSWORD_UNLOCK_DATA = MasterPasswordUnlockDataJson(
|
private val MOCK_MASTER_PASSWORD_UNLOCK_DATA = MasterPasswordUnlockDataJson(
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import com.bitwarden.core.DeriveKeyConnectorRequest
|
|||||||
import com.bitwarden.core.DerivePinKeyResponse
|
import com.bitwarden.core.DerivePinKeyResponse
|
||||||
import com.bitwarden.core.InitOrgCryptoRequest
|
import com.bitwarden.core.InitOrgCryptoRequest
|
||||||
import com.bitwarden.core.InitUserCryptoRequest
|
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.UpdatePasswordResponse
|
||||||
import com.bitwarden.core.data.util.asFailure
|
import com.bitwarden.core.data.util.asFailure
|
||||||
import com.bitwarden.core.data.util.asSuccess
|
import com.bitwarden.core.data.util.asSuccess
|
||||||
@ -1486,6 +1489,64 @@ class VaultSdkSourceTest {
|
|||||||
)
|
)
|
||||||
assertTrue(result.isFailure)
|
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>()
|
||||||
|
val error = BitwardenException.Crypto(CryptoException.MissingKey("mockException"))
|
||||||
|
coEvery {
|
||||||
|
clientCrypto.makeUpdateKdf(
|
||||||
|
password = "mockPassword",
|
||||||
|
kdf = kdf,
|
||||||
|
)
|
||||||
|
} throws error
|
||||||
|
val result = vaultSdkSource.makeUpdateKdf(
|
||||||
|
userId = "mockUserId",
|
||||||
|
password = "mockPassword",
|
||||||
|
kdf = kdf,
|
||||||
|
)
|
||||||
|
assertEquals(error.asFailure(), result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val DEFAULT_SIGNATURE = "0987654321ABCDEF"
|
private const val DEFAULT_SIGNATURE = "0987654321ABCDEF"
|
||||||
|
|||||||
@ -18,10 +18,12 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
|
|||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
|
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
||||||
|
import com.x8bit.bitwarden.data.auth.manager.KdfManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
|
||||||
import com.x8bit.bitwarden.data.auth.manager.model.LogoutEvent
|
import com.x8bit.bitwarden.data.auth.manager.model.LogoutEvent
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||||
|
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
|
||||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||||
import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState
|
import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState
|
||||||
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
|
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
|
||||||
@ -104,6 +106,12 @@ class VaultLockManagerTest {
|
|||||||
private val realtimeManager: RealtimeManager = mockk {
|
private val realtimeManager: RealtimeManager = mockk {
|
||||||
every { elapsedRealtimeMs } returns FIXED_CLOCK.millis()
|
every { elapsedRealtimeMs } returns FIXED_CLOCK.millis()
|
||||||
}
|
}
|
||||||
|
private val kdfManager: KdfManager = mockk {
|
||||||
|
every { needsKdfUpdateToMinimums() } returns false
|
||||||
|
coEvery {
|
||||||
|
updateKdfToMinimumsIfNeeded(password = any())
|
||||||
|
} returns UpdateKdfMinimumsResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
private val vaultLockManager: VaultLockManager = VaultLockManagerImpl(
|
private val vaultLockManager: VaultLockManager = VaultLockManagerImpl(
|
||||||
context = context,
|
context = context,
|
||||||
@ -117,6 +125,7 @@ class VaultLockManagerTest {
|
|||||||
userLogoutManager = userLogoutManager,
|
userLogoutManager = userLogoutManager,
|
||||||
trustedDeviceManager = trustedDeviceManager,
|
trustedDeviceManager = trustedDeviceManager,
|
||||||
dispatcherManager = fakeDispatcherManager,
|
dispatcherManager = fakeDispatcherManager,
|
||||||
|
kdfManager = kdfManager,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -779,6 +779,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
|
@Test
|
||||||
fun `syncing dialog should be displayed according to state`() {
|
fun `syncing dialog should be displayed according to state`() {
|
||||||
composeTestRule.assertNoDialogExists()
|
composeTestRule.assertNoDialogExists()
|
||||||
|
|||||||
@ -22,6 +22,7 @@ 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.LogoutReason
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
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.UserState
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
|
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
|
||||||
@ -148,6 +149,10 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||||||
every { hasPendingAccountAddition = any() } just runs
|
every { hasPendingAccountAddition = any() } just runs
|
||||||
every { logout(userId = any(), reason = any()) } just runs
|
every { logout(userId = any(), reason = any()) } just runs
|
||||||
every { switchAccount(any()) } answers { switchAccountResult }
|
every { switchAccount(any()) } answers { switchAccountResult }
|
||||||
|
every { needsKdfUpdateToMinimums() } returns false
|
||||||
|
coEvery {
|
||||||
|
updateKdfToMinimumsIfNeeded(password = any())
|
||||||
|
} returns UpdateKdfMinimumsResult.Success
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mutableFlightRecorderDataFlow =
|
private var mutableFlightRecorderDataFlow =
|
||||||
@ -2860,6 +2865,204 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `UpdatedKdfToMinimumsReceived with Success should clear dialog and send a ShowSnackbar event`() =
|
||||||
|
runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.eventFlow.test {
|
||||||
|
viewModel.trySendAction(
|
||||||
|
action = VaultAction.Internal.UpdatedKdfToMinimumsReceived(
|
||||||
|
result = UpdateKdfMinimumsResult.Success,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(dialog = null),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
VaultEvent.ShowSnackbar(BitwardenString.encryption_settings_updated.asText()),
|
||||||
|
awaitItem(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `UpdatedKdfToMinimumsReceived with ActiveAccountNotFound should show error dialog`() =
|
||||||
|
runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.trySendAction(
|
||||||
|
action = VaultAction.Internal.UpdatedKdfToMinimumsReceived(
|
||||||
|
result = UpdateKdfMinimumsResult.ActiveAccountNotFound,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
dialog = VaultState.DialogState.Error(
|
||||||
|
title = BitwardenString.an_error_has_occurred.asText(),
|
||||||
|
message = BitwardenString
|
||||||
|
.kdf_update_failed_active_account_not_found
|
||||||
|
.asText(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `UpdatedKdfToMinimumsReceived with Error should show error dialog`() = runTest {
|
||||||
|
val testError = Exception("Test error")
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
viewModel.trySendAction(
|
||||||
|
action = VaultAction.Internal.UpdatedKdfToMinimumsReceived(
|
||||||
|
result = UpdateKdfMinimumsResult.Error(testError),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
DEFAULT_STATE.copy(
|
||||||
|
dialog = VaultState.DialogState.Error(
|
||||||
|
title = BitwardenString.an_error_has_occurred.asText(),
|
||||||
|
message = BitwardenString
|
||||||
|
.an_error_occurred_while_trying_to_update_your_kdf_settings
|
||||||
|
.asText(),
|
||||||
|
error = testError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `vaultDataStateFlow Loaded with needsKdfUpdateToMinimums true should show KdfUpdateRequired dialog`() =
|
||||||
|
runTest {
|
||||||
|
coEvery { authRepository.needsKdfUpdateToMinimums() } returns true
|
||||||
|
mutableVaultDataStateFlow.value = DataState.Loaded(
|
||||||
|
data = VaultData(
|
||||||
|
decryptCipherListResult = createMockDecryptCipherListResult(
|
||||||
|
number = 1,
|
||||||
|
successes = listOf(
|
||||||
|
createMockCipherListView(
|
||||||
|
number = 1,
|
||||||
|
type = CipherListViewType.Login(
|
||||||
|
createMockLoginListView(number = 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createMockCipherListView(
|
||||||
|
number = 2,
|
||||||
|
type = CipherListViewType.Login(
|
||||||
|
createMockLoginListView(number = 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
failures = emptyList(),
|
||||||
|
),
|
||||||
|
collectionViewList = emptyList(),
|
||||||
|
folderViewList = emptyList(),
|
||||||
|
sendViewList = emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
createMockVaultState(
|
||||||
|
viewState = VaultState.ViewState.Content(
|
||||||
|
loginItemsCount = 2,
|
||||||
|
cardItemsCount = 0,
|
||||||
|
identityItemsCount = 0,
|
||||||
|
secureNoteItemsCount = 0,
|
||||||
|
favoriteItems = listOf(),
|
||||||
|
folderItems = listOf(),
|
||||||
|
collectionItems = listOf(),
|
||||||
|
noFolderItems = listOf(),
|
||||||
|
trashItemsCount = 0,
|
||||||
|
totpItemsCount = 2,
|
||||||
|
itemTypesCount = 5,
|
||||||
|
sshKeyItemsCount = 0,
|
||||||
|
showCardGroup = true,
|
||||||
|
),
|
||||||
|
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
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
fun `vaultDataStateFlow Loaded with needsKdfUpdateToMinimums false should not show KdfUpdateRequired dialog`() =
|
||||||
|
runTest {
|
||||||
|
coEvery { authRepository.needsKdfUpdateToMinimums() } returns false
|
||||||
|
mutableVaultDataStateFlow.value = DataState.Loaded(
|
||||||
|
data = VaultData(
|
||||||
|
decryptCipherListResult = createMockDecryptCipherListResult(
|
||||||
|
number = 1,
|
||||||
|
successes = listOf(
|
||||||
|
createMockCipherListView(
|
||||||
|
number = 1,
|
||||||
|
type = CipherListViewType.Login(
|
||||||
|
createMockLoginListView(number = 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createMockCipherListView(
|
||||||
|
number = 2,
|
||||||
|
type = CipherListViewType.Login(
|
||||||
|
createMockLoginListView(number = 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
failures = emptyList(),
|
||||||
|
),
|
||||||
|
collectionViewList = emptyList(),
|
||||||
|
folderViewList = emptyList(),
|
||||||
|
sendViewList = emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
createMockVaultState(
|
||||||
|
viewState = VaultState.ViewState.Content(
|
||||||
|
loginItemsCount = 2,
|
||||||
|
cardItemsCount = 0,
|
||||||
|
identityItemsCount = 0,
|
||||||
|
secureNoteItemsCount = 0,
|
||||||
|
favoriteItems = listOf(),
|
||||||
|
folderItems = listOf(),
|
||||||
|
collectionItems = listOf(),
|
||||||
|
noFolderItems = listOf(),
|
||||||
|
trashItemsCount = 0,
|
||||||
|
totpItemsCount = 2,
|
||||||
|
itemTypesCount = 5,
|
||||||
|
sshKeyItemsCount = 0,
|
||||||
|
showCardGroup = true,
|
||||||
|
),
|
||||||
|
dialog = null,
|
||||||
|
),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on KdfUpdatePasswordRepromptSubmit should call updateKdfToMinimumsIfNeeded`() = runTest {
|
||||||
|
val password = "mock_password"
|
||||||
|
coEvery {
|
||||||
|
authRepository.updateKdfToMinimumsIfNeeded(password)
|
||||||
|
} returns UpdateKdfMinimumsResult.Success
|
||||||
|
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
|
||||||
|
viewModel.trySendAction(
|
||||||
|
action = VaultAction.KdfUpdatePasswordRepromptSubmit(password = password),
|
||||||
|
)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) {
|
||||||
|
authRepository.updateKdfToMinimumsIfNeeded(password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("MaxLineLength")
|
@Suppress("MaxLineLength")
|
||||||
@Test
|
@Test
|
||||||
fun `CredentialExchangeProtocolExportFlagUpdateReceive should register for export when flag is enabled`() =
|
fun `CredentialExchangeProtocolExportFlagUpdateReceive should register for export when flag is enabled`() =
|
||||||
|
|||||||
@ -32,6 +32,7 @@ sealed class FlagKey<out T : Any> {
|
|||||||
listOf(
|
listOf(
|
||||||
CredentialExchangeProtocolImport,
|
CredentialExchangeProtocolImport,
|
||||||
CredentialExchangeProtocolExport,
|
CredentialExchangeProtocolExport,
|
||||||
|
ForceUpdateKdfSettings,
|
||||||
CipherKeyEncryption,
|
CipherKeyEncryption,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -71,6 +72,14 @@ sealed class FlagKey<out T : Any> {
|
|||||||
override val defaultValue: Boolean = false
|
override val defaultValue: Boolean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data object holding the feature flag key for the Force Update KDF Settings feature.
|
||||||
|
*/
|
||||||
|
data object ForceUpdateKdfSettings : FlagKey<Boolean>() {
|
||||||
|
override val keyName: String = "pm-18021-force-update-kdf-settings"
|
||||||
|
override val defaultValue: Boolean = false
|
||||||
|
}
|
||||||
|
|
||||||
//region Dummy keys for testing
|
//region Dummy keys for testing
|
||||||
/**
|
/**
|
||||||
* Data object holding the key for a [Boolean] flag to be used in tests.
|
* Data object holding the key for a [Boolean] flag to be used in tests.
|
||||||
|
|||||||
@ -24,6 +24,10 @@ class FlagKeyTest {
|
|||||||
FlagKey.BitwardenAuthenticationEnabled.keyName,
|
FlagKey.BitwardenAuthenticationEnabled.keyName,
|
||||||
"bitwarden-authentication-enabled",
|
"bitwarden-authentication-enabled",
|
||||||
)
|
)
|
||||||
|
assertEquals(
|
||||||
|
FlagKey.ForceUpdateKdfSettings.keyName,
|
||||||
|
"pm-18021-force-update-kdf-settings",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -34,6 +38,7 @@ class FlagKeyTest {
|
|||||||
FlagKey.CredentialExchangeProtocolExport,
|
FlagKey.CredentialExchangeProtocolExport,
|
||||||
FlagKey.CipherKeyEncryption,
|
FlagKey.CipherKeyEncryption,
|
||||||
FlagKey.BitwardenAuthenticationEnabled,
|
FlagKey.BitwardenAuthenticationEnabled,
|
||||||
|
FlagKey.ForceUpdateKdfSettings,
|
||||||
).all {
|
).all {
|
||||||
!it.defaultValue
|
!it.defaultValue
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import com.bitwarden.network.model.DeleteAccountRequestJson
|
|||||||
import com.bitwarden.network.model.NetworkResult
|
import com.bitwarden.network.model.NetworkResult
|
||||||
import com.bitwarden.network.model.ResetPasswordRequestJson
|
import com.bitwarden.network.model.ResetPasswordRequestJson
|
||||||
import com.bitwarden.network.model.SetPasswordRequestJson
|
import com.bitwarden.network.model.SetPasswordRequestJson
|
||||||
|
import com.bitwarden.network.model.UpdateKdfJsonRequest
|
||||||
import com.bitwarden.network.model.VerifyOtpRequestJson
|
import com.bitwarden.network.model.VerifyOtpRequestJson
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.HTTP
|
import retrofit2.http.HTTP
|
||||||
@ -36,6 +37,12 @@ internal interface AuthenticatedAccountsApi {
|
|||||||
@POST("/accounts/request-otp")
|
@POST("/accounts/request-otp")
|
||||||
suspend fun requestOtp(): NetworkResult<Unit>
|
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")
|
@POST("/accounts/verify-otp")
|
||||||
suspend fun verifyOtp(
|
suspend fun verifyOtp(
|
||||||
@Body body: VerifyOtpRequestJson,
|
@Body body: VerifyOtpRequestJson,
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.bitwarden.network.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the data used to authenticate with the master password.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MasterPasswordAuthenticationDataJson(
|
||||||
|
@SerialName("Kdf")
|
||||||
|
val kdf: KdfJson,
|
||||||
|
|
||||||
|
@SerialName("MasterPasswordAuthenticationHash")
|
||||||
|
val masterPasswordAuthenticationHash: String,
|
||||||
|
|
||||||
|
@SerialName("Salt")
|
||||||
|
val salt: String,
|
||||||
|
)
|
||||||
@ -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: MasterPasswordAuthenticationDataJson,
|
||||||
|
|
||||||
|
@SerialName("key")
|
||||||
|
val key: String,
|
||||||
|
|
||||||
|
@SerialName("masterPasswordHash")
|
||||||
|
val masterPasswordHash: String,
|
||||||
|
|
||||||
|
@SerialName("newMasterPasswordHash")
|
||||||
|
val newMasterPasswordHash: String,
|
||||||
|
|
||||||
|
@SerialName("unlockData")
|
||||||
|
val unlockData: MasterPasswordUnlockDataJson,
|
||||||
|
)
|
||||||
@ -8,6 +8,7 @@ import com.bitwarden.network.model.ResendEmailRequestJson
|
|||||||
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
|
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
|
||||||
import com.bitwarden.network.model.ResetPasswordRequestJson
|
import com.bitwarden.network.model.ResetPasswordRequestJson
|
||||||
import com.bitwarden.network.model.SetPasswordRequestJson
|
import com.bitwarden.network.model.SetPasswordRequestJson
|
||||||
|
import com.bitwarden.network.model.UpdateKdfJsonRequest
|
||||||
import com.bitwarden.network.model.VerificationCodeResponseJson
|
import com.bitwarden.network.model.VerificationCodeResponseJson
|
||||||
import com.bitwarden.network.model.VerificationOtpResponseJson
|
import com.bitwarden.network.model.VerificationOtpResponseJson
|
||||||
|
|
||||||
@ -115,4 +116,9 @@ interface AccountsService {
|
|||||||
accessToken: String,
|
accessToken: String,
|
||||||
masterKey: String,
|
masterKey: String,
|
||||||
): Result<Unit>
|
): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the KDF settings for the current account.
|
||||||
|
*/
|
||||||
|
suspend fun updateKdf(body: UpdateKdfJsonRequest): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import com.bitwarden.network.model.ResendEmailRequestJson
|
|||||||
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
|
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
|
||||||
import com.bitwarden.network.model.ResetPasswordRequestJson
|
import com.bitwarden.network.model.ResetPasswordRequestJson
|
||||||
import com.bitwarden.network.model.SetPasswordRequestJson
|
import com.bitwarden.network.model.SetPasswordRequestJson
|
||||||
|
import com.bitwarden.network.model.UpdateKdfJsonRequest
|
||||||
import com.bitwarden.network.model.VerificationCodeResponseJson
|
import com.bitwarden.network.model.VerificationCodeResponseJson
|
||||||
import com.bitwarden.network.model.VerificationOtpResponseJson
|
import com.bitwarden.network.model.VerificationOtpResponseJson
|
||||||
import com.bitwarden.network.model.VerifyOtpRequestJson
|
import com.bitwarden.network.model.VerifyOtpRequestJson
|
||||||
@ -209,4 +210,9 @@ internal class AccountsServiceImpl(
|
|||||||
body = KeyConnectorMasterKeyRequestJson(masterKey = masterKey),
|
body = KeyConnectorMasterKeyRequestJson(masterKey = masterKey),
|
||||||
)
|
)
|
||||||
.toResult()
|
.toResult()
|
||||||
|
|
||||||
|
override suspend fun updateKdf(body: UpdateKdfJsonRequest): Result<Unit> =
|
||||||
|
authenticatedAccountsApi
|
||||||
|
.updateKdf(body)
|
||||||
|
.toResult()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,15 +6,19 @@ import com.bitwarden.network.api.AuthenticatedKeyConnectorApi
|
|||||||
import com.bitwarden.network.api.UnauthenticatedAccountsApi
|
import com.bitwarden.network.api.UnauthenticatedAccountsApi
|
||||||
import com.bitwarden.network.api.UnauthenticatedKeyConnectorApi
|
import com.bitwarden.network.api.UnauthenticatedKeyConnectorApi
|
||||||
import com.bitwarden.network.base.BaseServiceTest
|
import com.bitwarden.network.base.BaseServiceTest
|
||||||
|
import com.bitwarden.network.model.KdfJson
|
||||||
import com.bitwarden.network.model.KdfTypeJson
|
import com.bitwarden.network.model.KdfTypeJson
|
||||||
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
|
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
|
||||||
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
|
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
|
||||||
|
import com.bitwarden.network.model.MasterPasswordAuthenticationDataJson
|
||||||
|
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
|
||||||
import com.bitwarden.network.model.PasswordHintResponseJson
|
import com.bitwarden.network.model.PasswordHintResponseJson
|
||||||
import com.bitwarden.network.model.RegisterRequestJson
|
import com.bitwarden.network.model.RegisterRequestJson
|
||||||
import com.bitwarden.network.model.ResendEmailRequestJson
|
import com.bitwarden.network.model.ResendEmailRequestJson
|
||||||
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
|
import com.bitwarden.network.model.ResendNewDeviceOtpRequestJson
|
||||||
import com.bitwarden.network.model.ResetPasswordRequestJson
|
import com.bitwarden.network.model.ResetPasswordRequestJson
|
||||||
import com.bitwarden.network.model.SetPasswordRequestJson
|
import com.bitwarden.network.model.SetPasswordRequestJson
|
||||||
|
import com.bitwarden.network.model.UpdateKdfJsonRequest
|
||||||
import com.bitwarden.network.model.VerificationCodeResponseJson
|
import com.bitwarden.network.model.VerificationCodeResponseJson
|
||||||
import com.bitwarden.network.model.VerificationOtpResponseJson
|
import com.bitwarden.network.model.VerificationOtpResponseJson
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@ -291,6 +295,25 @@ class AccountsServiceTest : BaseServiceTest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
fun `resendNewDeviceOtp with 400 response is Error`() = runTest {
|
fun `resendNewDeviceOtp with 400 response is Error`() = runTest {
|
||||||
val response = MockResponse().setResponseCode(400).setBody(INVALID_JSON)
|
val response = MockResponse().setResponseCode(400).setBody(INVALID_JSON)
|
||||||
server.enqueue(response)
|
server.enqueue(response)
|
||||||
@ -318,3 +341,29 @@ private const val INVALID_JSON = """
|
|||||||
"validationErrors": null
|
"validationErrors": null
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
private val UPDATE_KDF_REQUEST = UpdateKdfJsonRequest(
|
||||||
|
authenticationData = MasterPasswordAuthenticationDataJson(
|
||||||
|
kdf = KdfJson(
|
||||||
|
kdfType = KdfTypeJson.PBKDF2_SHA256,
|
||||||
|
iterations = 7,
|
||||||
|
memory = 1,
|
||||||
|
parallelism = 2,
|
||||||
|
),
|
||||||
|
masterPasswordAuthenticationHash = "mockMasterPasswordHash",
|
||||||
|
salt = "mockSalt",
|
||||||
|
),
|
||||||
|
key = "mockKey",
|
||||||
|
masterPasswordHash = "mockMasterPasswordHash",
|
||||||
|
newMasterPasswordHash = "mockNewMasterPasswordHash",
|
||||||
|
unlockData = MasterPasswordUnlockDataJson(
|
||||||
|
kdf = KdfJson(
|
||||||
|
kdfType = KdfTypeJson.PBKDF2_SHA256,
|
||||||
|
iterations = 7,
|
||||||
|
memory = 1,
|
||||||
|
parallelism = 2,
|
||||||
|
),
|
||||||
|
masterKeyWrappedUserKey = "mockMasterPasswordKey",
|
||||||
|
salt = "mockSalt",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@ -27,6 +27,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
|
|||||||
FlagKey.CredentialExchangeProtocolImport,
|
FlagKey.CredentialExchangeProtocolImport,
|
||||||
FlagKey.CredentialExchangeProtocolExport,
|
FlagKey.CredentialExchangeProtocolExport,
|
||||||
FlagKey.CipherKeyEncryption,
|
FlagKey.CipherKeyEncryption,
|
||||||
|
FlagKey.ForceUpdateKdfSettings,
|
||||||
-> {
|
-> {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
BooleanFlagItem(
|
BooleanFlagItem(
|
||||||
@ -71,6 +72,7 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
|
|||||||
FlagKey.CredentialExchangeProtocolImport -> stringResource(BitwardenString.cxp_import)
|
FlagKey.CredentialExchangeProtocolImport -> stringResource(BitwardenString.cxp_import)
|
||||||
FlagKey.CredentialExchangeProtocolExport -> stringResource(BitwardenString.cxp_export)
|
FlagKey.CredentialExchangeProtocolExport -> stringResource(BitwardenString.cxp_export)
|
||||||
FlagKey.CipherKeyEncryption -> stringResource(BitwardenString.cipher_key_encryption)
|
FlagKey.CipherKeyEncryption -> stringResource(BitwardenString.cipher_key_encryption)
|
||||||
|
FlagKey.ForceUpdateKdfSettings -> stringResource(BitwardenString.force_update_kdf_settings)
|
||||||
FlagKey.BitwardenAuthenticationEnabled -> {
|
FlagKey.BitwardenAuthenticationEnabled -> {
|
||||||
stringResource(BitwardenString.bitwarden_authentication_enabled)
|
stringResource(BitwardenString.bitwarden_authentication_enabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1091,6 +1091,10 @@ 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="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_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="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_desc_long">The new recommended encryption settings will improve your account security. Enter your master password to update now.</string>
|
||||||
<string name="import_was_cancelled_in_the_selected_app">Credential import was cancelled in the selected app. No items have been imported.</string>
|
<string name="import_was_cancelled_in_the_selected_app">Credential import was cancelled in the selected app. No items have been imported.</string>
|
||||||
<string name="import_cancelled">Import cancelled</string>
|
<string name="import_cancelled">Import cancelled</string>
|
||||||
<string name="no_items_imported">No items imported</string>
|
<string name="no_items_imported">No items imported</string>
|
||||||
@ -1128,5 +1132,7 @@ Do you want to switch to this account?</string>
|
|||||||
<string name="passkeys">Passkeys</string>
|
<string name="passkeys">Passkeys</string>
|
||||||
<string name="import_verb">Import</string>
|
<string name="import_verb">Import</string>
|
||||||
<string name="why_is_this_step_required">Why is this step required?</string>
|
<string name="why_is_this_step_required">Why is this step required?</string>
|
||||||
|
<string name="kdf_update_failed_active_account_not_found">Kdf update failed, active account not found. Please try again or contact us.</string>
|
||||||
|
<string name="an_error_occurred_while_trying_to_update_your_kdf_settings">An error occurred while trying to update your kdf settings. Please try again or contact us.</string>
|
||||||
<string name="the_import_request_could_not_be_processed">The import request could not be processed.</string>
|
<string name="the_import_request_could_not_be_processed">The import request could not be processed.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -36,6 +36,7 @@
|
|||||||
<string name="import_format_label_2fas_json">2FAS (no password)</string>
|
<string name="import_format_label_2fas_json">2FAS (no password)</string>
|
||||||
<string name="import_format_label_lastpass_json">LastPass (.json)</string>
|
<string name="import_format_label_lastpass_json">LastPass (.json)</string>
|
||||||
<string name="import_format_label_aegis_json">Aegis (.json)</string>
|
<string name="import_format_label_aegis_json">Aegis (.json)</string>
|
||||||
|
<string name="force_update_kdf_settings">Force update KDF settings</string>
|
||||||
|
|
||||||
<!-- endregion Debug Menu -->
|
<!-- endregion Debug Menu -->
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user