Updated on 2026-08-14
This commit is contained in:
parent
19d44f47d4
commit
7de4ad811d
9 changed files with 361 additions and 0 deletions
|
|
@ -3,6 +3,7 @@ apply plugin: 'org.jetbrains.dokka'
|
|||
apply plugin: 'com.github.dcendents.android-maven'
|
||||
apply from: '../dependencies.gradle'
|
||||
apply from: '../jitpack.gradle'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
group = "$jitpackSdk.group"
|
||||
version "$jitpackSdk.version"
|
||||
|
|
@ -22,6 +23,14 @@ dependencies {
|
|||
// misc
|
||||
implementation 'com.google.code.gson:gson:2.8.6'
|
||||
|
||||
//network
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.8.1'
|
||||
implementation 'com.squareup.retrofit2:converter-moshi:2.6.0'
|
||||
implementation 'com.squareup.moshi:moshi:1.9.2'
|
||||
implementation "com.squareup.moshi:moshi-kotlin:1.9.2"
|
||||
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.9.2")
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:4.2.2'
|
||||
|
||||
// tests
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.2'
|
||||
testImplementation "com.google.truth:truth:1.0.1"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import com.tangem.commands.personalization.entities.Acquirer
|
|||
import com.tangem.commands.personalization.entities.CardConfig
|
||||
import com.tangem.commands.personalization.entities.Issuer
|
||||
import com.tangem.commands.personalization.entities.Manufacturer
|
||||
import com.tangem.commands.verifycard.VerifyCardCommand
|
||||
import com.tangem.commands.verifycard.VerifyCardResponse
|
||||
import com.tangem.common.CompletionResult
|
||||
import com.tangem.common.TerminalKeysService
|
||||
import com.tangem.crypto.CryptoUtils
|
||||
|
|
@ -294,6 +296,27 @@ class TangemSdk(
|
|||
startSessionWithRunnable(PurgeWalletCommand(), cardId, initialMessage, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method launches a [VerifyCardCommand] on a new thread.
|
||||
*
|
||||
* The command to ensures the card has not been counterfeited.
|
||||
* By using standard challenge-response scheme, the card proves possession of CardPrivateKey
|
||||
* that corresponds to CardPublicKey returned by [ReadCommand]. Then the data is sent
|
||||
* to Tangem server to prove that this card was indeed issued by Tangem.
|
||||
* The online part of the verification is unavailable for DevKit cards.
|
||||
*
|
||||
*
|
||||
* @param cardId CID, Unique Tangem card ID number.
|
||||
* @param online flag that allows disable online verification
|
||||
* @param callback is triggered on the completion of the [VerifyCardCommand] and provides
|
||||
* card response in the form of [VerifyCardResponse] if the task was performed successfully
|
||||
* or [TangemSdkError] in case of an error.
|
||||
*/
|
||||
fun verify(cardId: String? = null, online: Boolean = true, initialMessage: Message? = null,
|
||||
callback: (result: CompletionResult<VerifyCardResponse>) -> Unit) {
|
||||
startSessionWithRunnable(VerifyCardCommand(online), cardId, initialMessage, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Command available on SDK cards only
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
package com.tangem.commands.common.network
|
||||
|
||||
import com.tangem.Log
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.IOException
|
||||
|
||||
suspend fun <T> retryIO(
|
||||
times: Int = 3,
|
||||
initialDelay: Long = 100,
|
||||
maxDelay: Long = 1000,
|
||||
factor: Double = 2.0,
|
||||
block: suspend () -> T): T
|
||||
{
|
||||
var currentDelay = initialDelay
|
||||
repeat(times - 1) {
|
||||
try {
|
||||
return block()
|
||||
} catch (e: IOException) {
|
||||
Log.i("Network", e.localizedMessage)
|
||||
}
|
||||
delay(currentDelay)
|
||||
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
|
||||
}
|
||||
return block()
|
||||
}
|
||||
|
||||
sealed class Result<out T> {
|
||||
data class Success<out T>(val data: T) : Result<T>()
|
||||
data class Failure(val error: Throwable?) : Result<Nothing>()
|
||||
}
|
||||
|
||||
suspend fun <T>performRequest(block: suspend () -> T): Result<T> {
|
||||
return try {
|
||||
val result = retryIO { block() }
|
||||
Result.Success(result)
|
||||
} catch (exception: Exception) {
|
||||
Result.Failure(exception)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.tangem.commands.common.network
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
|
||||
|
||||
class ApiTangem {
|
||||
companion object {
|
||||
const val TANGEM_ENDPOINT: String = "https://verify.tangem.com/"
|
||||
|
||||
const val VERIFY = "verify"
|
||||
const val VERIFY_AND_GET_INFO = "card/verify-and-get-info"
|
||||
const val ARTWORK = "card/artwork"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private val moshi: Moshi by lazy {
|
||||
Moshi.Builder()
|
||||
.add(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
}
|
||||
|
||||
fun createRetrofitInstance(baseUrl: String): Retrofit =
|
||||
Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.tangem.commands.common.network
|
||||
|
||||
|
||||
import com.tangem.commands.common.network.ApiTangem.Companion.VERIFY_AND_GET_INFO
|
||||
import com.tangem.commands.verifycard.CardVerifyAndGetInfo
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface TangemApi {
|
||||
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST(VERIFY_AND_GET_INFO)
|
||||
suspend fun getCardVerifyAndGetInfo(@Body requestBody: CardVerifyAndGetInfo.Request): CardVerifyAndGetInfo.Response
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.commands.common.network
|
||||
|
||||
import com.tangem.commands.verifycard.CardVerifyAndGetInfo
|
||||
|
||||
class TangemService {
|
||||
|
||||
private val tangemApi: TangemApi by lazy {
|
||||
createRetrofitInstance(ApiTangem.TANGEM_ENDPOINT).create(TangemApi::class.java)
|
||||
}
|
||||
|
||||
suspend fun verifyAndGetInfo(
|
||||
cardId: String,
|
||||
cardPublicKey: String
|
||||
): Result<CardVerifyAndGetInfo.Response> {
|
||||
val requestsBody = CardVerifyAndGetInfo.Request()
|
||||
requestsBody.requests =
|
||||
listOf(CardVerifyAndGetInfo.Request.Item(cardId, cardPublicKey))
|
||||
|
||||
return performRequest { tangemApi.getCardVerifyAndGetInfo(requestsBody) }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package com.tangem.commands.verifycard
|
||||
|
||||
import com.tangem.CardSession
|
||||
import com.tangem.SessionEnvironment
|
||||
import com.tangem.TangemSdkError
|
||||
import com.tangem.commands.Card
|
||||
import com.tangem.commands.CardStatus
|
||||
import com.tangem.commands.Command
|
||||
import com.tangem.commands.CommandResponse
|
||||
import com.tangem.commands.common.network.Result
|
||||
import com.tangem.commands.common.network.TangemService
|
||||
import com.tangem.common.CompletionResult
|
||||
import com.tangem.common.apdu.CommandApdu
|
||||
import com.tangem.common.apdu.Instruction
|
||||
import com.tangem.common.apdu.ResponseApdu
|
||||
import com.tangem.common.extensions.CardType
|
||||
import com.tangem.common.extensions.getType
|
||||
import com.tangem.common.extensions.toHexString
|
||||
import com.tangem.common.tlv.TlvBuilder
|
||||
import com.tangem.common.tlv.TlvDecoder
|
||||
import com.tangem.common.tlv.TlvTag
|
||||
import com.tangem.crypto.CryptoUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class VerifyCardResponse(
|
||||
val cardId: String,
|
||||
val verificationState: VerifyCardState? = null,
|
||||
val artworkInfo: ArtworkInfo? = null,
|
||||
internal val salt: ByteArray,
|
||||
internal val cardSignature: ByteArray
|
||||
) : CommandResponse {
|
||||
|
||||
fun verify(publicKey: ByteArray, challenge: ByteArray): Boolean {
|
||||
return CryptoUtils.verify(
|
||||
publicKey,
|
||||
challenge + salt,
|
||||
cardSignature
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class VerifyCardState {
|
||||
VerifiedOnline,
|
||||
VerifiedOffline,
|
||||
}
|
||||
|
||||
class VerifyCardCommand(private val onlineVerification: Boolean) : Command<VerifyCardResponse>() {
|
||||
|
||||
private val challenge = CryptoUtils.generateRandomBytes(16)
|
||||
private val tangemService = TangemService()
|
||||
|
||||
override fun run(
|
||||
session: CardSession,
|
||||
callback: (result: CompletionResult<VerifyCardResponse>) -> Unit
|
||||
) {
|
||||
val card = session.environment.card
|
||||
val cardPublicKey = card?.cardPublicKey
|
||||
if (cardPublicKey == null) {
|
||||
callback(CompletionResult.Failure(TangemSdkError.MissingPreflightRead()))
|
||||
return
|
||||
}
|
||||
super.run(session) { result ->
|
||||
when (result) {
|
||||
is CompletionResult.Failure -> {
|
||||
callback(CompletionResult.Failure(result.error))
|
||||
}
|
||||
is CompletionResult.Success -> {
|
||||
val response = result.data
|
||||
val verified = response.verify(cardPublicKey, challenge)
|
||||
if (!verified) {
|
||||
callback(CompletionResult.Failure(TangemSdkError.VerificationFailed()))
|
||||
return@run
|
||||
}
|
||||
if (!onlineVerification || card.getType() != CardType.Release) {
|
||||
callback(
|
||||
CompletionResult.Success(
|
||||
VerifyCardResponse(
|
||||
response.cardId, VerifyCardState.VerifiedOffline, null,
|
||||
response.salt, response.cardSignature
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
verify(result.data, card.cardId, cardPublicKey, session, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(
|
||||
response: VerifyCardResponse, cardId: String, cardPublicKey: ByteArray,
|
||||
session: CardSession,
|
||||
callback: (result: CompletionResult<VerifyCardResponse>) -> Unit
|
||||
) {
|
||||
session.scope.launch {
|
||||
val result = tangemService.verifyAndGetInfo(cardId, cardPublicKey.toHexString())
|
||||
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
if (result.data.results?.firstOrNull()?.passed == true) {
|
||||
callback(
|
||||
CompletionResult.Success(
|
||||
VerifyCardResponse(
|
||||
response.cardId, VerifyCardState.VerifiedOnline, response.artworkInfo,
|
||||
response.salt, response.cardSignature
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
callback(CompletionResult.Failure(TangemSdkError.VerificationFailed()))
|
||||
}
|
||||
}
|
||||
is Result.Failure -> {
|
||||
callback(
|
||||
CompletionResult.Success(
|
||||
VerifyCardResponse(
|
||||
response.cardId, VerifyCardState.VerifiedOffline, null,
|
||||
response.salt, response.cardSignature
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun performPreCheck(card: Card): TangemSdkError? {
|
||||
if (card.status == CardStatus.NotPersonalized) {
|
||||
return TangemSdkError.NotPersonalized()
|
||||
}
|
||||
if (card.isActivated) {
|
||||
return TangemSdkError.NotActivated()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun serialize(environment: SessionEnvironment): CommandApdu {
|
||||
val tlvBuilder = TlvBuilder()
|
||||
tlvBuilder.append(TlvTag.Pin, environment.pin1)
|
||||
tlvBuilder.append(TlvTag.CardId, environment.card?.cardId)
|
||||
tlvBuilder.append(TlvTag.Challenge, challenge)
|
||||
return CommandApdu(Instruction.VerifyCard, tlvBuilder.serialize())
|
||||
}
|
||||
|
||||
override fun deserialize(
|
||||
environment: SessionEnvironment,
|
||||
apdu: ResponseApdu
|
||||
): VerifyCardResponse {
|
||||
val tlvData = apdu.getTlvData() ?: throw TangemSdkError.DeserializeApduFailed()
|
||||
|
||||
val decoder = TlvDecoder(tlvData)
|
||||
return VerifyCardResponse(
|
||||
cardId = decoder.decode(TlvTag.CardId),
|
||||
salt = decoder.decode(TlvTag.Salt),
|
||||
cardSignature = decoder.decode(TlvTag.CardSignature)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.tangem.commands.verifycard
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
|
||||
class CardVerifyAndGetInfo {
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Request(
|
||||
var requests: List<Item>? = null
|
||||
) {
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Item(
|
||||
var CID: String = "",
|
||||
var publicKey: String = ""
|
||||
)
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Response(
|
||||
var results: List<Item>? = null
|
||||
) {
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Item(
|
||||
var error: String? = null,
|
||||
var CID: String = "",
|
||||
var passed: Boolean = false,
|
||||
var batch: String = "",
|
||||
var artwork: ArtworkInfo? = null,
|
||||
var substitution: SubstitutionInfo? = null
|
||||
) {
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class SubstitutionInfo(
|
||||
var data: String? = null,
|
||||
var signature: String? = null
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ArtworkInfo(
|
||||
var id: String = "",
|
||||
var hash: String = "",
|
||||
var date: String = ""
|
||||
) {
|
||||
fun getUpdateDate(): Date? {
|
||||
return try {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US).parse(date)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
android:name="android.hardware.nfc"
|
||||
android:required="true" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
|
||||
|
||||
<application
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue