Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-03 18:40:43 +04:00
parent 80f829a2aa
commit d468010146
16 changed files with 457 additions and 58 deletions

View file

@ -2,7 +2,13 @@ package com.tangem.data.pay.repository
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.data.pay.util.RainCryptoUtil
import com.tangem.data.visa.config.VisaLibLoader
import com.tangem.datasource.api.common.config.ApiConfig
import com.tangem.datasource.api.common.config.ApiEnvironment
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.CardDetailsRequest
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.pay.repository.CardDetailsRepository
@ -13,6 +19,9 @@ private const val TAG = "TangemPay: CardDetailsRepository"
internal class DefaultCardDetailsRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val visaLibLoader: VisaLibLoader,
private val apiConfigsManager: ApiConfigsManager,
private val rainCryptoUtil: RainCryptoUtil,
) : CardDetailsRepository {
override suspend fun getCardBalance(): Either<UniversalError, TangemPayCardBalance> {
@ -28,6 +37,47 @@ internal class DefaultCardDetailsRepository @Inject constructor(
}
override suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails> {
TODO("[REDACTED_TASK_KEY] add reveal card details")
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyHex, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val result = requestHelper.request { authHeader ->
tangemPayApi.revealCardDetails(
authHeader = authHeader,
body = CardDetailsRequest(sessionId = sessionId),
)
}.result ?: error("Cannot reveal card details")
val pan = rainCryptoUtil.decryptSecret(
base64Secret = result.pan.secret,
base64Iv = result.pan.iv,
secretKeyHex = secretKeyHex,
)
val cvv = rainCryptoUtil.decryptSecret(
base64Secret = result.cvv.secret,
base64Iv = result.cvv.iv,
secretKeyHex = secretKeyHex,
)
TangemPayCardDetails(
pan = pan,
cvv = cvv,
expirationYear = result.expirationYear,
expirationMonth = result.expirationMonth,
)
}
}
private suspend fun getPublicKeyBase64(): String {
val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment
return when (env) {
ApiEnvironment.DEV,
ApiEnvironment.DEV_2,
ApiEnvironment.STAGE,
ApiEnvironment.MOCK,
-> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.dev
ApiEnvironment.PROD -> visaLibLoader.getOrCreateConfig().rainRSAPublicKey.prod
}
}
}

View file

@ -21,6 +21,7 @@ import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.wallets.legacy.UserWalletsListManager
import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
@ -58,8 +59,15 @@ internal class TangemPayRequestPerformer @Inject constructor(
val result = requestBlock()
Either.Right(result)
} catch (exception: Exception) {
Timber.e("$tag: $exception")
Either.Left(mapError(exception))
when (exception) {
is CancellationException -> {
throw exception
}
else -> {
Timber.tag(tag).e(exception)
Either.Left(mapError(exception))
}
}
}
}

View file

@ -0,0 +1,69 @@
package com.tangem.data.pay.util
import android.util.Base64
import com.tangem.utils.extensions.hexToBytes
import java.nio.charset.StandardCharsets
import java.security.KeyFactory
import java.security.SecureRandom
import com.tangem.common.extensions.toHexString
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
private const val KEY_LENGTH_BYTES = 16
private const val TAG_LENGTH_BYTES = 16
private const val BITS_PER_BYTE = 8
private const val RSA_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"
private const val AES_TRANSFORMATION = "AES/GCM/NoPadding"
private const val AES_ALGORITHM = "AES"
private const val RSA_ALGORITHM = "RSA"
internal class RainCryptoUtil @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair<String, String> =
withContext(dispatchers.default) {
val secretKeyHex = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }.toHexString()
val sessionId = generateSessionId(publicKeyBase64, secretKeyHex)
secretKeyHex to sessionId
}
suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyHex: String): String =
withContext(dispatchers.default) {
val cipherTextBytes = Base64.decode(base64Secret, Base64.NO_WRAP)
if (cipherTextBytes.size < TAG_LENGTH_BYTES) error("Cipher text too short")
val initializationVectorBytes = Base64.decode(base64Iv, Base64.NO_WRAP)
val secretKeyBytes = secretKeyHex.hexToBytes()
val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM)
val gcmParameterSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, initializationVectorBytes)
val aesCipher = Cipher.getInstance(AES_TRANSFORMATION)
aesCipher.init(Cipher.DECRYPT_MODE, aesSecretKey, gcmParameterSpec)
val plaintextBytes = aesCipher.doFinal(cipherTextBytes)
plaintextBytes.toString(StandardCharsets.UTF_8).trim().ifEmpty { error("Invalid decrypted data") }
}
private fun generateSessionId(publicKeyBase64: String, secretKeyHex: String): String {
val secretKeyBytes = secretKeyHex.hexToBytes()
val publicKeyDerBytes = Base64.decode(publicKeyBase64, Base64.NO_WRAP)
val publicKeySpec = X509EncodedKeySpec(publicKeyDerBytes)
val rsaPublicKey = KeyFactory.getInstance(RSA_ALGORITHM).generatePublic(publicKeySpec)
val secretKeyBase64String = Base64.encodeToString(secretKeyBytes, Base64.NO_WRAP)
val plaintextUtf8Bytes = secretKeyBase64String.toByteArray(StandardCharsets.UTF_8)
val rsaCipher = Cipher.getInstance(RSA_TRANSFORMATION)
rsaCipher.init(Cipher.ENCRYPT_MODE, rsaPublicKey)
val cipherTextBytes = rsaCipher.doFinal(plaintextUtf8Bytes)
return Base64.encodeToString(cipherTextBytes, Base64.NO_WRAP)
}
}

View file

@ -13,6 +13,8 @@ internal data class VisaConfig(
val header: Header,
@Json(name = "rsaPublicKey")
val rsaPublicKey: RsaPublicKey,
@Json(name = "rainRSAPublicKey")
val rainRSAPublicKey: RsaPublicKey,
) {
@JsonClass(generateAdapter = true)