mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 09:56:45 -06:00
[PM-23278] Add func to update to minimum kdf settings to AuthRepository
This commit is contained in:
parent
a0bbc1a313
commit
0ff6bb4aeb
@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.datasource.sdk.util
|
||||
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.network.model.KdfJsonRequest
|
||||
import com.bitwarden.network.model.KdfTypeJson
|
||||
import com.bitwarden.network.model.KdfTypeJson.ARGON2_ID
|
||||
import com.bitwarden.network.model.KdfTypeJson.PBKDF2_SHA256
|
||||
@ -13,3 +14,22 @@ fun Kdf.toKdfTypeJson(): KdfTypeJson =
|
||||
is Kdf.Argon2id -> ARGON2_ID
|
||||
is Kdf.Pbkdf2 -> PBKDF2_SHA256
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a [Kdf] to [KdfJsonRequest]
|
||||
*/
|
||||
fun Kdf.toKdfRequestModel(): KdfJsonRequest =
|
||||
when (this) {
|
||||
is Kdf.Argon2id -> KdfJsonRequest(
|
||||
kdfType = toKdfTypeJson(),
|
||||
iterations = iterations.toInt(),
|
||||
memory = memory.toInt(),
|
||||
parallelism = parallelism.toInt(),
|
||||
)
|
||||
is Kdf.Pbkdf2 -> KdfJsonRequest(
|
||||
kdfType = toKdfTypeJson(),
|
||||
iterations = iterations.toInt(),
|
||||
memory = null,
|
||||
parallelism = null,
|
||||
)
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
||||
@ -351,6 +352,16 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager, UserStateM
|
||||
*/
|
||||
suspend fun getPasswordStrength(email: String? = null, password: String): PasswordStrengthResult
|
||||
|
||||
/**
|
||||
* Checks if their current settings are below the minimums and needs update
|
||||
*/
|
||||
suspend fun needsKdfUpdateToMinimums(): Boolean
|
||||
|
||||
/**
|
||||
* Updates the user's KDF settings if their current settings are below the minimums
|
||||
*/
|
||||
suspend fun updateKdfToMinimumsIfNeeded(password: String): UpdateKdfMinimumsResult
|
||||
|
||||
/**
|
||||
* Validates the master password for the current logged in user.
|
||||
*/
|
||||
|
||||
@ -15,6 +15,9 @@ import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
|
||||
import com.bitwarden.network.model.DeleteAccountResponseJson
|
||||
import com.bitwarden.network.model.GetTokenResponseJson
|
||||
import com.bitwarden.network.model.IdentityTokenAuthModel
|
||||
import com.bitwarden.network.model.KdfTypeJson
|
||||
import com.bitwarden.network.model.MasterPasswordAuthenticationDataJsonRequest
|
||||
import com.bitwarden.network.model.MasterPasswordUnlockDataJsonRequest
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.network.model.PasswordHintResponseJson
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
@ -33,6 +36,7 @@ import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
|
||||
import com.bitwarden.network.model.TwoFactorAuthMethod
|
||||
import com.bitwarden.network.model.TwoFactorDataModel
|
||||
import com.bitwarden.network.model.UpdateKdfJsonRequest
|
||||
import com.bitwarden.network.model.VerifyEmailTokenRequestJson
|
||||
import com.bitwarden.network.model.VerifyEmailTokenResponseJson
|
||||
import com.bitwarden.network.service.AccountsService
|
||||
@ -49,6 +53,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeviceDataModel
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
|
||||
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
|
||||
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
|
||||
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
|
||||
@ -77,6 +82,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
||||
@ -129,6 +135,8 @@ import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.time.Clock
|
||||
import javax.inject.Singleton
|
||||
import kotlin.text.set
|
||||
import kotlin.text.toInt
|
||||
|
||||
/**
|
||||
* Default implementation of [AuthRepository].
|
||||
@ -1212,6 +1220,98 @@ class AuthRepositoryImpl(
|
||||
onFailure = { PasswordStrengthResult.Error(error = it) },
|
||||
)
|
||||
|
||||
override suspend fun needsKdfUpdateToMinimums(): Boolean {
|
||||
val account = authDiskSource
|
||||
.userState
|
||||
?.accounts
|
||||
?.get(activeUserId)
|
||||
?: return false
|
||||
|
||||
return account.profile.kdfType == KdfTypeJson.PBKDF2_SHA256 &&
|
||||
account.profile.kdfIterations != null &&
|
||||
account.profile.kdfIterations < DEFAULT_PBKDF2_ITERATIONS
|
||||
}
|
||||
|
||||
override suspend fun updateKdfToMinimumsIfNeeded(password: String): UpdateKdfMinimumsResult {
|
||||
val userId = activeUserId ?: return UpdateKdfMinimumsResult.ActiveAccountNotFound
|
||||
val account = authDiskSource.userState?.accounts?.get(userId)
|
||||
?: return UpdateKdfMinimumsResult.ActiveAccountNotFound
|
||||
account.profile
|
||||
|
||||
// Check if needs update kdf
|
||||
if (!needsKdfUpdateToMinimums()) {
|
||||
return UpdateKdfMinimumsResult.Success
|
||||
}
|
||||
|
||||
// Generate updated KDF data
|
||||
val updateKdfResponse = vaultSdkSource.makeUpdateKdf(
|
||||
userId = userId,
|
||||
password = password,
|
||||
kdf = account.profile.toSdkParams(),
|
||||
).getOrElse { error ->
|
||||
return UpdateKdfMinimumsResult.Error(error = error)
|
||||
}
|
||||
|
||||
val authData = updateKdfResponse.masterPasswordAuthenticationData
|
||||
val oldAuthData = updateKdfResponse.oldMasterPasswordAuthenticationData
|
||||
val unlockData = updateKdfResponse.masterPasswordUnlockData
|
||||
// Send update to server
|
||||
val updateKdfRequest = UpdateKdfJsonRequest(
|
||||
authenticationData = MasterPasswordAuthenticationDataJsonRequest(
|
||||
kdf = authData.kdf.toKdfRequestModel(),
|
||||
masterPasswordAuthenticationHash =
|
||||
authData.masterPasswordAuthenticationHash,
|
||||
salt = authData.salt,
|
||||
),
|
||||
key = unlockData.masterKeyWrappedUserKey,
|
||||
masterPasswordHash = oldAuthData.masterPasswordAuthenticationHash,
|
||||
newMasterPasswordHash = authData.masterPasswordAuthenticationHash,
|
||||
unlockData = MasterPasswordUnlockDataJsonRequest(
|
||||
kdf = unlockData.kdf.toKdfRequestModel(),
|
||||
masterKeyWrappedUserKey = unlockData.masterKeyWrappedUserKey,
|
||||
salt = unlockData.salt,
|
||||
),
|
||||
)
|
||||
|
||||
accountsService
|
||||
.updateKdf(body = updateKdfRequest)
|
||||
.getOrElse { error ->
|
||||
return UpdateKdfMinimumsResult.Error(error = error)
|
||||
}
|
||||
|
||||
// TODO CHECK IF WE NEED TO SAVE NEW VALUES TO STATE
|
||||
/**
|
||||
// Update local storage
|
||||
authDiskSource.storeMasterPasswordHash(
|
||||
userId = profile.userId,
|
||||
passwordHash = updateKdfResponse
|
||||
.masterPasswordAuthenticationData.masterPasswordAuthenticationHash,
|
||||
)
|
||||
authDiskSource.storeUserKey(
|
||||
userId = profile.userId,
|
||||
userKey = updateKdfResponse.masterPasswordUnlockData.masterKeyWrappedUserKey,
|
||||
)
|
||||
|
||||
// Update profile with new KDF parameters
|
||||
val updatedProfile = profile.copy(
|
||||
kdfType = authData.kdf.toKdfRequestModel().kdfType,
|
||||
kdfIterations = authData.kdf.toKdfRequestModel().iterations,
|
||||
kdfMemory = authData.kdf.toKdfRequestModel().memory,
|
||||
kdfParallelism = authData.kdf.toKdfRequestModel().parallelism,
|
||||
)
|
||||
|
||||
val updatedUserState = authDiskSource.userState?.copy(
|
||||
accounts = authDiskSource.userState!!.accounts.toMutableMap().apply {
|
||||
this[profile.userId] = account.copy(profile = updatedProfile)
|
||||
}
|
||||
)
|
||||
authDiskSource.userState = updatedUserState
|
||||
|
||||
**/
|
||||
|
||||
return UpdateKdfMinimumsResult.Success
|
||||
}
|
||||
|
||||
override suspend fun validatePassword(password: String): ValidatePasswordResult {
|
||||
val userId = activeUserId ?: return ValidatePasswordResult.Error(NoActiveUserException())
|
||||
return authDiskSource
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
/**
|
||||
* Models result of updating a user's kdf settings to minimums
|
||||
*/
|
||||
sealed class UpdateKdfMinimumsResult {
|
||||
/**
|
||||
* Active account was not found
|
||||
*/
|
||||
object ActiveAccountNotFound : UpdateKdfMinimumsResult()
|
||||
|
||||
/**
|
||||
* Account with userId was not found
|
||||
*/
|
||||
object AccountNotFound : UpdateKdfMinimumsResult()
|
||||
|
||||
/**
|
||||
* There was an error updating user to minimum kdf settings.
|
||||
*
|
||||
* @param error the error.
|
||||
*/
|
||||
data class Error(
|
||||
val error: Throwable?,
|
||||
) : UpdateKdfMinimumsResult()
|
||||
|
||||
/**
|
||||
* Updated user to minimum kdf settings successfully.
|
||||
*/
|
||||
object Success : UpdateKdfMinimumsResult()
|
||||
}
|
||||
@ -5,8 +5,11 @@ import com.bitwarden.core.AuthRequestMethod
|
||||
import com.bitwarden.core.AuthRequestResponse
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.KeyConnectorResponse
|
||||
import com.bitwarden.core.MasterPasswordAuthenticationData
|
||||
import com.bitwarden.core.MasterPasswordUnlockData
|
||||
import com.bitwarden.core.RegisterKeyResponse
|
||||
import com.bitwarden.core.RegisterTdeKeyResponse
|
||||
import com.bitwarden.core.UpdateKdfResponse
|
||||
import com.bitwarden.core.UpdatePasswordResponse
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
@ -99,6 +102,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
||||
@ -158,7 +162,8 @@ import java.time.ZonedDateTime
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class AuthRepositoryTest {
|
||||
class
|
||||
AuthRepositoryTest {
|
||||
|
||||
private val dispatcherManager: DispatcherManager = FakeDispatcherManager()
|
||||
private val accountsService: AccountsService = mockk()
|
||||
@ -6888,6 +6893,212 @@ class AuthRepositoryTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needsKdfUpdateToMinimums with no active user should return false`() = runTest {
|
||||
fakeAuthDiskSource.userState = null
|
||||
|
||||
val result = repository.needsKdfUpdateToMinimums()
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needsKdfUpdateToMinimums with kdfType null should return false`() = runTest {
|
||||
val nullKdfProfile = PROFILE_1.copy(
|
||||
kdfType = null,
|
||||
kdfIterations = null,
|
||||
kdfMemory = null,
|
||||
kdfParallelism = null,
|
||||
)
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy(
|
||||
accounts = mapOf(
|
||||
USER_ID_1 to ACCOUNT_1.copy(profile = nullKdfProfile),
|
||||
),
|
||||
)
|
||||
|
||||
val result = repository.needsKdfUpdateToMinimums()
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needsKdfUpdateToMinimums with PBKDF2 below minimum iterations should return true`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
|
||||
|
||||
val result = repository.needsKdfUpdateToMinimums()
|
||||
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needsKdfUpdateToMinimums with PBKDF2 meeting minimum iterations should return false`() =
|
||||
runTest {
|
||||
val sufficientIterationsProfile = PROFILE_1.copy(
|
||||
kdfType = KdfTypeJson.PBKDF2_SHA256,
|
||||
kdfIterations = 600000, // Meets minimum
|
||||
kdfMemory = null,
|
||||
kdfParallelism = null,
|
||||
)
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy(
|
||||
accounts = mapOf(
|
||||
USER_ID_1 to ACCOUNT_1.copy(profile = sufficientIterationsProfile),
|
||||
),
|
||||
)
|
||||
|
||||
val result = repository.needsKdfUpdateToMinimums()
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needsKdfUpdateToMinimums with Argon2id below minimum parameters should return false`() =
|
||||
runTest {
|
||||
val lowArgon2idProfile = PROFILE_1.copy(
|
||||
kdfType = KdfTypeJson.ARGON2_ID,
|
||||
kdfIterations = 1, // Below minimum of 3
|
||||
kdfMemory = 16, // Below minimum of 64
|
||||
kdfParallelism = 1, // Below minimum of 4
|
||||
)
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy(
|
||||
accounts = mapOf(
|
||||
USER_ID_1 to ACCOUNT_1.copy(profile = lowArgon2idProfile),
|
||||
),
|
||||
)
|
||||
|
||||
val result = repository.needsKdfUpdateToMinimums()
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needsKdfUpdateToMinimums with Argon2id meeting minimum parameters should return false`() =
|
||||
runTest {
|
||||
val sufficientArgon2idProfile = PROFILE_1.copy(
|
||||
kdfType = KdfTypeJson.ARGON2_ID,
|
||||
kdfIterations = 600000, // Meets minimum
|
||||
kdfMemory = 64,
|
||||
kdfParallelism = 4,
|
||||
)
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy(
|
||||
accounts = mapOf(
|
||||
USER_ID_1 to ACCOUNT_1.copy(profile = sufficientArgon2idProfile),
|
||||
),
|
||||
)
|
||||
|
||||
val result = repository.needsKdfUpdateToMinimums()
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateKdfToMinimumsIfNeeded with no active user should return ActiveAccountNotFound`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = null
|
||||
|
||||
val result = repository.updateKdfToMinimumsIfNeeded(password = PASSWORD)
|
||||
|
||||
assertEquals(
|
||||
UpdateKdfMinimumsResult.ActiveAccountNotFound,
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateKdfToMinimumsIfNeeded with minimum Kdf iterations should return Success`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy(
|
||||
accounts = mapOf(
|
||||
USER_ID_1 to ACCOUNT_1.copy(
|
||||
profile = PROFILE_1.copy(
|
||||
kdfType = KdfTypeJson.PBKDF2_SHA256,
|
||||
kdfIterations = 600000,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = repository.updateKdfToMinimumsIfNeeded(password = PASSWORD)
|
||||
|
||||
assertEquals(
|
||||
UpdateKdfMinimumsResult.Success,
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateKdfToMinimumsIfNeeded if sdk throws an error should return Error`() = runTest {
|
||||
val error = Throwable("Kdf update failed")
|
||||
coEvery {
|
||||
vaultSdkSource.makeUpdateKdf(
|
||||
userId = any(),
|
||||
password = any(),
|
||||
kdf = any(),
|
||||
)
|
||||
} returns error.asFailure()
|
||||
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
|
||||
|
||||
val result = repository.updateKdfToMinimumsIfNeeded(password = PASSWORD)
|
||||
|
||||
assertEquals(
|
||||
UpdateKdfMinimumsResult.Error(error = error),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `updateKdfToMinimumsIfNeeded with PBKDF2 below minimums and updateKdf API failure should return Error`() = runTest {
|
||||
val error = Throwable("API failed")
|
||||
coEvery {
|
||||
vaultSdkSource.makeUpdateKdf(
|
||||
userId = any(),
|
||||
password = any(),
|
||||
kdf = any(),
|
||||
)
|
||||
} returns UPDATE_KDF_RESPONSE.asSuccess()
|
||||
|
||||
coEvery {
|
||||
accountsService.updateKdf(any())
|
||||
} returns error.asFailure()
|
||||
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
|
||||
|
||||
val result = repository.updateKdfToMinimumsIfNeeded(password = PASSWORD)
|
||||
|
||||
assertEquals(UpdateKdfMinimumsResult.Error(error = error), result)
|
||||
coVerify(exactly = 1) {
|
||||
accountsService.updateKdf(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("MaxLineLength")
|
||||
fun `updateKdfToMinimumsIfNeeded with PBKDF2 below minimums should return Success`() =
|
||||
runTest {
|
||||
coEvery {
|
||||
vaultSdkSource.makeUpdateKdf(
|
||||
userId = any(),
|
||||
password = any(),
|
||||
kdf = any(),
|
||||
)
|
||||
} returns UPDATE_KDF_RESPONSE.asSuccess()
|
||||
|
||||
coEvery {
|
||||
accountsService.updateKdf(any())
|
||||
} returns Unit.asSuccess()
|
||||
|
||||
fakeAuthDiskSource.userState = SINGLE_USER_STATE_2
|
||||
|
||||
val result = repository.updateKdfToMinimumsIfNeeded(password = PASSWORD)
|
||||
|
||||
assertEquals(UpdateKdfMinimumsResult.Success, result)
|
||||
coVerify(exactly = 1) {
|
||||
accountsService.updateKdf(any())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val FIXED_CLOCK: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
@ -7132,5 +7343,23 @@ class AuthRepositoryTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val UPDATE_KDF_RESPONSE = UpdateKdfResponse(
|
||||
masterPasswordAuthenticationData = MasterPasswordAuthenticationData(
|
||||
kdf = mockk<Kdf>(relaxed = true),
|
||||
salt = "mockSalt",
|
||||
masterPasswordAuthenticationHash = "mockHash",
|
||||
),
|
||||
masterPasswordUnlockData = MasterPasswordUnlockData(
|
||||
kdf = mockk<Kdf>(relaxed = true),
|
||||
masterKeyWrappedUserKey = "mockKey",
|
||||
salt = "mockSalt",
|
||||
),
|
||||
oldMasterPasswordAuthenticationData = MasterPasswordAuthenticationData(
|
||||
kdf = mockk<Kdf>(relaxed = true),
|
||||
salt = "mockSalt",
|
||||
masterPasswordAuthenticationHash = "mockHash",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user