Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-27 13:07:28 +04:00
parent 5579cc1372
commit a04992591a
24 changed files with 707 additions and 29 deletions

View file

@ -0,0 +1,6 @@
package com.tangem.data.pay.entity
internal data class EncryptedData(
val encryptedBase64: String,
val ivBase64: String,
)

View file

@ -9,6 +9,8 @@ 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.datasource.api.pay.models.request.SetPinRequest
import com.tangem.domain.pay.model.SetPinResult
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.domain.pay.model.TangemPayCardDetails
import com.tangem.domain.pay.repository.CardDetailsRepository
@ -39,7 +41,7 @@ internal class DefaultCardDetailsRepository @Inject constructor(
override suspend fun revealCardDetails(): Either<UniversalError, TangemPayCardDetails> {
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyHex, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val result = requestHelper.request { authHeader ->
tangemPayApi.revealCardDetails(
@ -51,14 +53,15 @@ internal class DefaultCardDetailsRepository @Inject constructor(
val pan = rainCryptoUtil.decryptSecret(
base64Secret = result.pan.secret,
base64Iv = result.pan.iv,
secretKeyHex = secretKeyHex,
secretKeyBytes = secretKeyBytes,
)
val cvv = rainCryptoUtil.decryptSecret(
base64Secret = result.cvv.secret,
base64Iv = result.cvv.iv,
secretKeyHex = secretKeyHex,
secretKeyBytes = secretKeyBytes,
)
secretKeyBytes.fill(0)
TangemPayCardDetails(
pan = pan,
@ -69,6 +72,32 @@ internal class DefaultCardDetailsRepository @Inject constructor(
}
}
override suspend fun setPin(pin: String): Either<UniversalError, SetPinResult> {
return requestHelper.runWithErrorLogs(TAG) {
val publicKeyBase64 = getPublicKeyBase64()
val (secretKeyBytes, sessionId) = rainCryptoUtil.generateSecretKeyAndSessionId(publicKeyBase64)
val encryptedData = rainCryptoUtil.encryptPin(pin = pin, secretKeyBytes = secretKeyBytes)
secretKeyBytes.fill(0)
val status = requestHelper.request { authHeader ->
tangemPayApi.setPin(
authHeader = authHeader,
body = SetPinRequest(
sessionId = sessionId,
pin = encryptedData.encryptedBase64,
iv = encryptedData.ivBase64,
),
)
}.result?.result ?: error("Cannot set pin code")
when (status) {
SetPinResult.SUCCESS.name -> SetPinResult.SUCCESS
SetPinResult.PIN_TOO_WEAK.name -> SetPinResult.PIN_TOO_WEAK
SetPinResult.DECRYPTION_ERROR.name -> SetPinResult.DECRYPTION_ERROR
else -> SetPinResult.UNKNOWN_ERROR
}
}
}
private suspend fun getPublicKeyBase64(): String {
val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment
return when (env) {

View file

@ -1,11 +1,10 @@
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.data.pay.entity.EncryptedData
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import java.security.spec.X509EncodedKeySpec
@ -21,26 +20,38 @@ 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"
private const val IV_LENGTH_BYTES = 16
private const val PIN_LENGTH = 4
private const val PIN_LENGTH_BYTES = 8
private const val PIN_BLOCK_ISO_9564_FORMAT_PREFIX = '2'
private const val PIN_BLOCK_FILL_CHAR = 'F'
internal class RainCryptoUtil @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair<String, String> =
suspend fun generateSecretKeyAndSessionId(publicKeyBase64: String): Pair<ByteArray, String> =
withContext(dispatchers.default) {
val secretKeyHex = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }.toHexString()
val sessionId = generateSessionId(publicKeyBase64, secretKeyHex)
secretKeyHex to sessionId
val secretKeyBytes = ByteArray(KEY_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
val sessionId = generateSessionId(publicKeyBase64, secretKeyBytes)
secretKeyBytes to sessionId
}
suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyHex: String): String =
suspend fun encryptPin(pin: String, secretKeyBytes: ByteArray): EncryptedData = withContext(dispatchers.default) {
val bytes = pinBlockByteArray(pin)
try {
encryptSecret(bytes, secretKeyBytes)
} finally {
bytes.clear()
}
}
suspend fun decryptSecret(base64Secret: String, base64Iv: String, secretKeyBytes: ByteArray): 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)
@ -51,8 +62,25 @@ internal class RainCryptoUtil @Inject constructor(
plaintextBytes.toString(StandardCharsets.UTF_8).trim().ifEmpty { error("Invalid decrypted data") }
}
private fun generateSessionId(publicKeyBase64: String, secretKeyHex: String): String {
val secretKeyBytes = secretKeyHex.hexToBytes()
private fun encryptSecret(bytes: ByteArray, secretKeyBytes: ByteArray): EncryptedData {
val iv = ByteArray(IV_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
val aesSecretKey = SecretKeySpec(secretKeyBytes, AES_ALGORITHM)
val gcmSpec = GCMParameterSpec(TAG_LENGTH_BYTES * BITS_PER_BYTE, iv)
val cipher = Cipher.getInstance(AES_TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, aesSecretKey, gcmSpec)
val ciphertext = cipher.doFinal(bytes)
bytes.clear()
return EncryptedData(
encryptedBase64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP),
ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP),
)
}
private fun generateSessionId(publicKeyBase64: String, secretKeyBytes: ByteArray): String {
val publicKeyDerBytes = Base64.decode(publicKeyBase64, Base64.NO_WRAP)
val publicKeySpec = X509EncodedKeySpec(publicKeyDerBytes)
val rsaPublicKey = KeyFactory.getInstance(RSA_ALGORITHM).generatePublic(publicKeySpec)
@ -66,4 +94,26 @@ internal class RainCryptoUtil @Inject constructor(
return Base64.encodeToString(cipherTextBytes, Base64.NO_WRAP)
}
/**
* Formats PIN into a PIN block using schema: [Prefix][Length][PIN][fill with F].
* Example: 246784FFFFFFFFFF for PIN 6784.
*/
private fun pinBlockByteArray(pin: String): ByteArray {
require(pin.length == PIN_LENGTH) { "PIN length must be $PIN_LENGTH" }
require(pin.all { it.isDigit() }) { "PIN must contain digits only" }
val pinBlockHexLength = PIN_LENGTH_BYTES * 2
val hex = buildString(pinBlockHexLength) {
append(PIN_BLOCK_ISO_9564_FORMAT_PREFIX)
append(PIN_LENGTH.toString())
append(pin)
while (length < pinBlockHexLength) append(PIN_BLOCK_FILL_CHAR)
}
return hex.toByteArray(StandardCharsets.UTF_8)
}
private fun ByteArray.clear() {
for (i in indices) this[i] = 0
}
}