Request certificates aggregation
This commit is contained in:
parent
13ae15491f
commit
b2d9bd3edb
@ -56,6 +56,7 @@ dependencies {
|
||||
implementation 'com.github.eu-digital-green-certificates:dgca-app-core-android:e4ad73eef8'
|
||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
|
||||
implementation 'org.bouncycastle:bcprov-jdk15to18:1.70'
|
||||
|
||||
implementation "com.squareup.retrofit2:retrofit:2.9.0"
|
||||
implementation "com.squareup.okhttp3:okhttp-tls:4.9.3"
|
||||
|
@ -0,0 +1,43 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Authors
|
||||
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
* Created by Lunabee Studio / Date - 2020/13/05 - for the TOUS-ANTI-COVID project
|
||||
*/
|
||||
|
||||
package com.lunabeestudio.domain.extension
|
||||
|
||||
import javax.crypto.SecretKey
|
||||
import javax.security.auth.DestroyFailedException
|
||||
|
||||
fun SecretKey.safeDestroy() {
|
||||
try {
|
||||
destroy()
|
||||
} catch (e: DestroyFailedException) {
|
||||
// Destroy not implemented
|
||||
} catch (e: NoSuchMethodError) {
|
||||
// Destroy not implemented
|
||||
}
|
||||
}
|
||||
|
||||
val SecretKey.safeIsDestroyed: Boolean?
|
||||
get() = try {
|
||||
isDestroyed
|
||||
} catch (e: DestroyFailedException) {
|
||||
// Destroy not implemented
|
||||
null
|
||||
} catch (e: NoSuchMethodError) {
|
||||
// Destroy not implemented
|
||||
null
|
||||
}
|
||||
|
||||
fun <T> SecretKey.safeUse(block: (SecretKey) -> T): T {
|
||||
try {
|
||||
return block(this)
|
||||
} finally {
|
||||
safeDestroy()
|
||||
}
|
||||
}
|
@ -0,0 +1,122 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Authors
|
||||
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
* Created by Lunabee Studio / Date - 2020/20/05 - for the TOUS-ANTI-COVID project
|
||||
*/
|
||||
|
||||
package com.lunabeestudio.framework.crypto
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import com.lunabeestudio.domain.extension.safeUse
|
||||
import com.lunabeestudio.framework.utils.SelfDestroyCipherOutputStream
|
||||
import com.lunabeestudio.robert.datasource.SharedCryptoDataSource
|
||||
import com.lunabeestudio.robert.extension.use
|
||||
import org.bouncycastle.jce.ECNamedCurveTable
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
import org.bouncycastle.jce.spec.ECParameterSpec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.SecureRandom
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyAgreement
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class BouncyCastleCryptoDataSource : SharedCryptoDataSource {
|
||||
override fun createECDHKeyPair(): KeyPair {
|
||||
val ecSpec: ECParameterSpec = ECNamedCurveTable.getParameterSpec(NAMED_CURVE_SPEC)
|
||||
val bouncyCastleProvider = BouncyCastleProvider()
|
||||
val keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM_EC, bouncyCastleProvider)
|
||||
keyPairGenerator.initialize(ecSpec, SecureRandom())
|
||||
return keyPairGenerator.generateKeyPair()
|
||||
}
|
||||
|
||||
override fun getEncryptionKeys(
|
||||
rawServerPublicKey: ByteArray,
|
||||
rawLocalPrivateKey: ByteArray,
|
||||
derivationDataArray: List<ByteArray>,
|
||||
): List<ByteArray> {
|
||||
val bouncyCastleProvider = BouncyCastleProvider()
|
||||
val keyFactory = KeyFactory.getInstance(ALGORITHM_EC, bouncyCastleProvider)
|
||||
val serverPublicKey = keyFactory.generatePublic(X509EncodedKeySpec(rawServerPublicKey))
|
||||
val localPrivateKey = keyFactory.generatePrivate(PKCS8EncodedKeySpec(rawLocalPrivateKey))
|
||||
|
||||
val keyAgreement = KeyAgreement.getInstance(ALGORITHM_ECDH)
|
||||
|
||||
keyAgreement.init(localPrivateKey)
|
||||
keyAgreement.doPhase(serverPublicKey, true)
|
||||
|
||||
return keyAgreement.generateSecret().use { sharedSecret ->
|
||||
SecretKeySpec(sharedSecret, HASH_HMACSHA256).safeUse { secretKeySpec ->
|
||||
val hmac = Mac.getInstance(HASH_HMACSHA256)
|
||||
hmac.init(secretKeySpec)
|
||||
derivationDataArray.map {
|
||||
hmac.doFinal(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun decrypt(key: ByteArray, encryptedData: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(AES_GCM_CIPHER_TYPE)
|
||||
val iv: ByteArray = encryptedData.copyOfRange(0, AES_GCM_IV_LENGTH)
|
||||
val ivSpec = GCMParameterSpec(AES_GCM_TAG_LENGTH_IN_BITS, iv)
|
||||
|
||||
return SecretKeySpec(key, ALGORITHM_AES).safeUse { secretKey ->
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
|
||||
cipher.doFinal(encryptedData, AES_GCM_IV_LENGTH, encryptedData.size - AES_GCM_IV_LENGTH)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
override fun encrypt(key: ByteArray, clearData: ByteArray): ByteArray {
|
||||
val secretKey = SecretKeySpec(key, HASH_HMACSHA256)
|
||||
val bos = ByteArrayOutputStream()
|
||||
|
||||
val cipher = Cipher.getInstance(AES_GCM_CIPHER_TYPE)
|
||||
val iv: ByteArray = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
cipher.iv
|
||||
} else {
|
||||
val iv = ByteArray(AES_GCM_IV_LENGTH)
|
||||
SecureRandom().nextBytes(iv)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(iv))
|
||||
iv
|
||||
}
|
||||
|
||||
bos.write(iv)
|
||||
|
||||
SelfDestroyCipherOutputStream(bos, cipher, secretKey).use { cos ->
|
||||
clearData.inputStream().use { input ->
|
||||
input.copyTo(cos, BUFFER_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
return bos.toByteArray()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val HASH_HMACSHA256 = "HmacSHA256"
|
||||
private const val NAMED_CURVE_SPEC = "secp256r1"
|
||||
private const val ALGORITHM_EC = "EC"
|
||||
private const val ALGORITHM_ECDH = "ECDH"
|
||||
private const val ALGORITHM_AES = "AES"
|
||||
|
||||
private const val AES_GCM_CIPHER_TYPE = "AES/GCM/NoPadding"
|
||||
private const val AES_GCM_IV_LENGTH = 12
|
||||
private const val AES_GCM_TAG_LENGTH_IN_BITS = 128
|
||||
|
||||
private const val BUFFER_SIZE = 4 * 256
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Authors
|
||||
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
* Created by Lunabee Studio / Date - 2021/22/9 - for the TOUS-ANTI-COVID project
|
||||
*/
|
||||
|
||||
package com.lunabeestudio.framework.crypto
|
||||
|
||||
import android.util.Base64
|
||||
import com.lunabeestudio.robert.RobertConstant
|
||||
import com.lunabeestudio.robert.datasource.SharedCryptoDataSource
|
||||
import com.lunabeestudio.robert.extension.use
|
||||
|
||||
internal class ServerKeyAgreementHelper(
|
||||
private val sharedCryptoDataSource: SharedCryptoDataSource,
|
||||
) {
|
||||
|
||||
private var localPrivateKey: ByteArray? = null
|
||||
|
||||
fun encryptRequestData(
|
||||
serverPublicKey: String,
|
||||
encodedDataList: List<String>,
|
||||
): ServerKeyAgreementData {
|
||||
val rawServerKey = Base64.decode(serverPublicKey, Base64.NO_WRAP)
|
||||
|
||||
val keyPair = sharedCryptoDataSource.createECDHKeyPair()
|
||||
|
||||
val encodedLocalPublicKey = Base64.encodeToString(keyPair.public.encoded, Base64.NO_WRAP)
|
||||
val serverKeyAgreementData = sharedCryptoDataSource.getEncryptionKeys(
|
||||
rawServerPublicKey = rawServerKey,
|
||||
rawLocalPrivateKey = keyPair.private.encoded,
|
||||
derivationDataArray = listOf(
|
||||
RobertConstant.CONVERSION_STRING_INPUT.toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
).first().let {
|
||||
localPrivateKey = it
|
||||
val encryptedDataList = encodedDataList.map { encodedData ->
|
||||
sharedCryptoDataSource.encrypt(it, encodedData.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val encodedEncryptedData = encryptedDataList.map { encryptedData ->
|
||||
Base64.encodeToString(encryptedData, Base64.NO_WRAP)
|
||||
}
|
||||
ServerKeyAgreementData(encodedLocalPublicKey, encodedEncryptedData)
|
||||
}
|
||||
|
||||
return serverKeyAgreementData
|
||||
}
|
||||
|
||||
@Throws(IllegalStateException::class)
|
||||
fun decryptResponse(encryptedData: String): String {
|
||||
val data = Base64.decode(encryptedData, Base64.NO_WRAP)
|
||||
return try {
|
||||
(localPrivateKey ?: throw IllegalStateException("localPrivateKey is null. getKeyAgreementData must be call first")).use {
|
||||
sharedCryptoDataSource.decrypt(it, data).toString(Charsets.UTF_8)
|
||||
}
|
||||
} finally {
|
||||
localPrivateKey = null
|
||||
}
|
||||
}
|
||||
|
||||
class ServerKeyAgreementData(
|
||||
val encodedLocalPublicKey: String,
|
||||
val encryptedData: List<String>,
|
||||
)
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Authors
|
||||
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
* Created by Lunabee Studio / Date - 2020/20/05 - for the TOUS-ANTI-COVID project
|
||||
*/
|
||||
|
||||
package com.lunabeestudio.framework.utils
|
||||
|
||||
import com.lunabeestudio.domain.extension.safeDestroy
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.crypto.CipherOutputStream
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
class SelfDestroyCipherInputStream(inputStream: InputStream, cipher: Cipher, private val key: SecretKey) : CipherInputStream(
|
||||
inputStream,
|
||||
cipher
|
||||
) {
|
||||
override fun close() {
|
||||
super.close()
|
||||
key.safeDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
class SelfDestroyCipherOutputStream(outputStream: OutputStream, cipher: Cipher, private val key: SecretKey) : CipherOutputStream(
|
||||
outputStream,
|
||||
cipher
|
||||
) {
|
||||
override fun close() {
|
||||
super.close()
|
||||
key.safeDestroy()
|
||||
}
|
||||
}
|
29
app/src/main/java/com/lunabeestudio/robert/RobertConstant.kt
Normal file
29
app/src/main/java/com/lunabeestudio/robert/RobertConstant.kt
Normal file
@ -0,0 +1,29 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Authors
|
||||
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
* Created by Lunabee Studio / Date - 2020/04/05 - for the TOUS-ANTI-COVID project
|
||||
*/
|
||||
|
||||
package com.lunabeestudio.robert
|
||||
|
||||
|
||||
object RobertConstant {
|
||||
const val STATUS_WORKER_NAME: String = "RobertManager.Status.Worker"
|
||||
const val EPOCH_DURATION_S: Int = 15 * 60
|
||||
const val KA_STRING_INPUT: String = "mac"
|
||||
const val KEA_STRING_INPUT: String = "tuples"
|
||||
const val DEFAULT_CALIBRATION_KEY: String = "DEFAULT"
|
||||
const val LAST_CONTACT_DELTA_S: Long = 24 * 60 * 60
|
||||
const val CONVERSION_STRING_INPUT: String = "conversion"
|
||||
|
||||
object PREFIX {
|
||||
const val C1: Byte = 0b00000001
|
||||
const val C2: Byte = 0b00000010
|
||||
const val C3: Byte = 0b00000011
|
||||
const val C4: Byte = 0b00000100
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Authors
|
||||
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
* Created by Lunabee Studio / Date - 2020/20/05 - for the TOUS-ANTI-COVID project
|
||||
*/
|
||||
|
||||
package com.lunabeestudio.robert.datasource
|
||||
|
||||
import java.security.KeyPair
|
||||
|
||||
interface SharedCryptoDataSource {
|
||||
fun createECDHKeyPair(): KeyPair
|
||||
fun getEncryptionKeys(
|
||||
rawServerPublicKey: ByteArray,
|
||||
rawLocalPrivateKey: ByteArray,
|
||||
derivationDataArray: List<ByteArray>
|
||||
): List<ByteArray>
|
||||
|
||||
fun decrypt(key: ByteArray, encryptedData: ByteArray): ByteArray
|
||||
fun encrypt(key: ByteArray, clearData: ByteArray): ByteArray
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
/*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*
|
||||
* Authors
|
||||
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
* Created by Lunabee Studio / Date - 2020/04/05 - for the TOUS-ANTI-COVID project
|
||||
*/
|
||||
|
||||
package com.lunabeestudio.robert.extension
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
fun ByteArray.randomize() {
|
||||
for (i in 0 until size) {
|
||||
set(i, SecureRandom().nextInt().toByte())
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> ByteArray.use(block: (ByteArray) -> T): T {
|
||||
return try {
|
||||
block(this)
|
||||
} finally {
|
||||
this.randomize()
|
||||
}
|
||||
}
|
@ -14,4 +14,7 @@ const val TEST_ANTIGENIC = "LP217198-3"
|
||||
|
||||
|
||||
// TousAntiCovid resources
|
||||
const val TAC_RES_BASE_URL = "https://app.tousanticovid.gouv.fr/json/version-38/"
|
||||
const val TAC_RES_BASE_URL = "https://app.tousanticovid.gouv.fr/json/version-38/"
|
||||
|
||||
// Dcc light generation server
|
||||
const val DCC_LIGHT_BASE_URL: String = "https://dcclight.tousanticovid.gouv.fr/"
|
@ -0,0 +1,93 @@
|
||||
package org.communiquons.dccaggregator.apis
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.lunabeestudio.framework.crypto.BouncyCastleCryptoDataSource
|
||||
import com.lunabeestudio.framework.crypto.ServerKeyAgreementHelper
|
||||
import org.communiquons.dccaggregator.DCC_LIGHT_BASE_URL
|
||||
import org.communiquons.dccaggregator.data.Certificate
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
internal class ApiAggregateRQ(
|
||||
val publicKey: String,
|
||||
val certificates: List<String>,
|
||||
)
|
||||
|
||||
internal class ApiAggregateRS(
|
||||
val response: String,
|
||||
)
|
||||
|
||||
internal class ApiAggregateCertificate(
|
||||
val certificate: String,
|
||||
)
|
||||
|
||||
|
||||
internal interface DccLightApi {
|
||||
@POST("/api/v1/aggregate")
|
||||
suspend fun aggregate(
|
||||
@Body apiAggregateRQ: ApiAggregateRQ,
|
||||
): Response<ApiAggregateRS>
|
||||
}
|
||||
|
||||
sealed class AggregateResult {
|
||||
class Error(val error: String, val throwable: Throwable?) : AggregateResult()
|
||||
class Success(val cert: Certificate) : AggregateResult()
|
||||
}
|
||||
|
||||
class DccLightAPIClient {
|
||||
private val api by lazy {
|
||||
getRetrofitService(DCC_LIGHT_BASE_URL, DccLightApi::class.java)
|
||||
}
|
||||
|
||||
private val serverKeyAgreementHelper by lazy {
|
||||
ServerKeyAgreementHelper(BouncyCastleCryptoDataSource())
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
suspend fun aggregateCertificates(
|
||||
serverPubKey: String,
|
||||
certs: List<Certificate>
|
||||
): AggregateResult {
|
||||
// Encrypt request
|
||||
val serverKeyAgreementData = try {
|
||||
serverKeyAgreementHelper.encryptRequestData(serverPubKey, certs.map { c -> c.encoded })
|
||||
} catch (e: Exception) {
|
||||
return AggregateResult.Error(e.message ?: "Failed to cipher request", e)
|
||||
}
|
||||
|
||||
val apiAggregateRQ = ApiAggregateRQ(
|
||||
serverKeyAgreementData.encodedLocalPublicKey,
|
||||
serverKeyAgreementData.encryptedData,
|
||||
)
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
return try {
|
||||
val response = api.aggregate(apiAggregateRQ)
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let {
|
||||
val multipassJson = serverKeyAgreementHelper.decryptResponse(it.response)
|
||||
val multipass = gson.fromJson(
|
||||
multipassJson,
|
||||
ApiAggregateCertificate::class.java
|
||||
).certificate
|
||||
AggregateResult.Success(Certificate(multipass))
|
||||
} ?: AggregateResult.Error(
|
||||
"Response successful (${response.code()}) but body is empty",
|
||||
null
|
||||
)
|
||||
|
||||
} else {
|
||||
val error = response.errorBody()?.string()
|
||||
if (error != null) {
|
||||
AggregateResult.Error("Aggregate request failed! $error", null)
|
||||
} else {
|
||||
AggregateResult.Error("Request failed with status ${response.code()}", null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return AggregateResult.Error("Request failed", e)
|
||||
}
|
||||
}
|
||||
}
|
@ -9,10 +9,13 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.communiquons.dccaggregator.apis.AggregateResult
|
||||
import org.communiquons.dccaggregator.apis.DccLightAPIClient
|
||||
import org.communiquons.dccaggregator.apis.TousAntiCovidAPIClient
|
||||
import org.communiquons.dccaggregator.helper.CertificatesList
|
||||
|
||||
class CertsManagerViewModel() : ViewModel() {
|
||||
private val TAG = CertsManagerViewModel::class.java.canonicalName
|
||||
|
||||
lateinit var certsManager: CertificatesList
|
||||
|
||||
@ -27,7 +30,17 @@ class CertsManagerViewModel() : ViewModel() {
|
||||
val config = TousAntiCovidAPIClient().getConfig()
|
||||
Log.d("Server Primary Key", config.activityPassGenerationServerPublicKey)
|
||||
|
||||
throw java.lang.Exception("TODO: send request")
|
||||
// Then aggregate certificates
|
||||
val res = DccLightAPIClient().aggregateCertificates(
|
||||
config.activityPassGenerationServerPublicKey,
|
||||
certsManager.certsList
|
||||
)
|
||||
|
||||
if (res is AggregateResult.Success) {
|
||||
Log.d(TAG, res.cert.encoded)
|
||||
} else if (res is AggregateResult.Error) {
|
||||
throw Exception(res.error)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
_reqStatus.value = null
|
||||
|
Loading…
Reference in New Issue
Block a user