From 431b1e2ded2a815b3d89e28489766792f8a3a514 Mon Sep 17 00:00:00 2001 From: iMaks99 Date: Fri, 8 Nov 2024 16:08:16 +0500 Subject: [PATCH] AND-9038 Added Sign transactions with different sizes of hashes --- .../tangem/sdk/extensions/TangemSdkError.kt | 1 - .../com/tangem/common/core/TangemError.kt | 5 - .../operations/sign/ChunkHashesUtils.kt | 25 +++ .../operations/sign/ChunkedHashesContainer.kt | 30 ++++ .../com/tangem/operations/sign/SignCommand.kt | 125 +++++++------- .../com/tangem/operations/sign/SignDTO.kt | 21 +++ .../com/tangem/operations/ChunkHashesTests.kt | 152 ++++++++++++++++++ 7 files changed, 294 insertions(+), 65 deletions(-) create mode 100644 tangem-sdk-core/src/main/java/com/tangem/operations/sign/ChunkHashesUtils.kt create mode 100644 tangem-sdk-core/src/main/java/com/tangem/operations/sign/ChunkedHashesContainer.kt create mode 100644 tangem-sdk-core/src/main/java/com/tangem/operations/sign/SignDTO.kt create mode 100644 tangem-sdk-core/src/test/java/com/tangem/operations/ChunkHashesTests.kt diff --git a/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdkError.kt b/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdkError.kt index ff501749..c4a8580a 100644 --- a/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdkError.kt +++ b/tangem-sdk-android/src/main/java/com/tangem/sdk/extensions/TangemSdkError.kt @@ -94,7 +94,6 @@ fun TangemSdkError.localizedDescriptionRes(): TangemSdkErrorDescription { is TangemSdkError.UnsupportedWalletConfig, is TangemSdkError.WalletIsNotCreated, is TangemSdkError.WalletIsPurged, - is TangemSdkError.HashSizeMustBeEqual, is TangemSdkError.SignHashesNotAvailable, is TangemSdkError.CardVerificationFailed, is TangemSdkError.NonHardenedDerivationNotSupported, diff --git a/tangem-sdk-core/src/main/java/com/tangem/common/core/TangemError.kt b/tangem-sdk-core/src/main/java/com/tangem/common/core/TangemError.kt index c7c380a5..488f202a 100644 --- a/tangem-sdk-core/src/main/java/com/tangem/common/core/TangemError.kt +++ b/tangem-sdk-core/src/main/java/com/tangem/common/core/TangemError.kt @@ -161,11 +161,6 @@ sealed class TangemSdkError(code: Int) : TangemError(code) { */ class EmptyHashes : TangemSdkError(code = 40902) - /** - * This error is returned when a [com.tangem.operations.SignCommand] - * receives hashes of different lengths for signature. - */ - class HashSizeMustBeEqual : TangemSdkError(code = 40903) class WalletIsNotCreated : TangemSdkError(code = 40904) class SignHashesNotAvailable : TangemSdkError(code = 40905) diff --git a/tangem-sdk-core/src/main/java/com/tangem/operations/sign/ChunkHashesUtils.kt b/tangem-sdk-core/src/main/java/com/tangem/operations/sign/ChunkHashesUtils.kt new file mode 100644 index 00000000..5d21e5bb --- /dev/null +++ b/tangem-sdk-core/src/main/java/com/tangem/operations/sign/ChunkHashesUtils.kt @@ -0,0 +1,25 @@ +package com.tangem.operations.sign + +internal object ChunkHashesUtils { + // The max answer is 1152 bytes (unencrypted) and 1120 (encrypted). + // The worst case is 8 hashes * 64 bytes for ed + 512 bytes of signatures + cardId, SignedHashes + TLV + SW is ok. + private const val PACKAGE_SIZE = 512 + // Card limitation + private const val MAX_CHUNK_SIZE = 10 + + fun chunkHashes(hashesRaw: Array): List { + val hashes = hashesRaw.mapIndexed { index, hash -> Hash(index = index, data = hash) } + val hashesBySize = hashes.groupBy { it.data.size } + + return hashesBySize.flatMap { hashesGroup -> + val hashSize = hashesGroup.key + val chunkSize = getChunkSize(hashSize) + + hashesGroup.value + .chunked(chunkSize) + .map { Chunk(hashSize, it) } + } + } + + private fun getChunkSize(hashSize: Int) = (PACKAGE_SIZE / hashSize).coerceIn(1, MAX_CHUNK_SIZE) +} diff --git a/tangem-sdk-core/src/main/java/com/tangem/operations/sign/ChunkedHashesContainer.kt b/tangem-sdk-core/src/main/java/com/tangem/operations/sign/ChunkedHashesContainer.kt new file mode 100644 index 00000000..e231cb7a --- /dev/null +++ b/tangem-sdk-core/src/main/java/com/tangem/operations/sign/ChunkedHashesContainer.kt @@ -0,0 +1,30 @@ +package com.tangem.operations.sign + +class ChunkedHashesContainer( + hashes: Array, +) { + val isEmpty: Boolean = hashes.isEmpty() + var currentChunkIndex: Int = 0 + private set + + private val chunks = ChunkHashesUtils.chunkHashes(hashes) + val chunksCount = chunks.size + + private var signedChunks: MutableList = mutableListOf() + + fun getCurrentChunk(): Chunk { + return chunks[currentChunkIndex] + } + + fun addSignedChunk(signedChunk: SignedChunk) { + signedChunks.add(signedChunk) + currentChunkIndex++ + } + + fun getSignatures(): List { + return signedChunks + .flatMap { it.signedHashes } + .sortedBy { it.index } + .map { it.signature } + } +} diff --git a/tangem-sdk-core/src/main/java/com/tangem/operations/sign/SignCommand.kt b/tangem-sdk-core/src/main/java/com/tangem/operations/sign/SignCommand.kt index a6ae8aab..d3edf6a6 100644 --- a/tangem-sdk-core/src/main/java/com/tangem/operations/sign/SignCommand.kt +++ b/tangem-sdk-core/src/main/java/com/tangem/operations/sign/SignCommand.kt @@ -19,16 +19,14 @@ import com.tangem.common.core.SessionEnvironment import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.guard import com.tangem.common.extensions.toByteArray -import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.common.tlv.TlvBuilder import com.tangem.common.tlv.TlvDecoder import com.tangem.common.tlv.TlvTag import com.tangem.crypto.CryptoUtils +import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.sign import com.tangem.operations.Command import com.tangem.operations.CommandResponse -import kotlin.math.max -import kotlin.math.min /** * @property cardId CID, Unique Tangem card ID number @@ -36,11 +34,29 @@ import kotlin.math.min * @property totalSignedHashes Total number of signed hashes returned by the wallet since its creation. COS: 1.16+ */ @JsonClass(generateAdapter = true) -class SignResponse( - val cardId: String, +open class SignResponse( + open val cardId: String, val signatures: List, - val totalSignedHashes: Int?, -) : CommandResponse + open val totalSignedHashes: Int?, +) : CommandResponse { + + /** + * Model to store chunked sign response. + * @property cardId CID, Unique Tangem card ID number + * @property signedChunk Signed hashes of given chunk + * @property totalSignedHashes Total number of signed hashes returned by the wallet since its creation. COS: 1.16+ + */ + @JsonClass(generateAdapter = true) + data class PartialSignResponse( + override val cardId: String, + val signedChunk: SignedChunk, + override val totalSignedHashes: Int?, + ) : SignResponse( + cardId = cardId, + signatures = emptyList(), + totalSignedHashes = totalSignedHashes, + ) +} /** * Signs transaction hashes using a wallet private key, stored on the card. @@ -56,20 +72,9 @@ internal class SignCommand( private var terminalKeys: KeyPair? = null - private val hashSizes = if (hashes.isNotEmpty()) hashes.first().size else 0 - private val chunkSize: Int = if (hashSizes > 0) { - val estimatedChunkSize = PACKAGE_SIZE / hashSizes - max(1, min(estimatedChunkSize, MAX_CHUNK_SIZE)) - } else { - MAX_CHUNK_SIZE - } - private val hashesChunked = hashes.asList().chunked(chunkSize) + private val chunkHashesHelper: ChunkedHashesContainer = ChunkedHashesContainer(hashes) private var environment: SessionEnvironment? = null - private val signatures = mutableListOf() - private val currentChunkNumber: Int - get() = signatures.size / chunkSize - override fun requiresPasscode(): Boolean = true override fun performPreCheck(card: Card): TangemSdkError? { @@ -104,14 +109,10 @@ internal class SignCommand( callback(CompletionResult.Failure(TangemSdkError.MissingPreflightRead())) return } - if (hashes.isEmpty()) { + if (chunkHashesHelper.isEmpty) { callback(CompletionResult.Failure(TangemSdkError.EmptyHashes())) return } - if (hashes.any { it.size != hashSizes }) { - callback(CompletionResult.Failure(TangemSdkError.HashSizeMustBeEqual())) - return - } terminalKeys = retrieveTerminalKeys(card, session.environment) sign(session, callback) @@ -119,13 +120,16 @@ internal class SignCommand( private fun sign(session: CardSession, callback: CompletionCallback) { environment = session.environment - if (hashesChunked.size > 1) setSignedChunksMessage(session) + if (chunkHashesHelper.chunksCount > 1) setSignedChunksMessage(session) transceive(session) { result -> when (result) { is CompletionResult.Success -> { - signatures.addAll(result.data.signatures) - if (signatures.size == hashes.size) { + val response = result.data as SignResponse.PartialSignResponse + chunkHashesHelper.addSignedChunk(response.signedChunk) + + if (chunkHashesHelper.currentChunkIndex >= chunkHashesHelper.chunksCount) { + val signatures = processSignatures(session.environment) session.environment.card?.wallet(walletPublicKey)?.let { val wallet = it.copy( totalSignedHashes = result.data.totalSignedHashes, @@ -135,15 +139,16 @@ internal class SignCommand( } val finalResponse = SignResponse( - result.data.cardId, - processSignatures(session.environment, signatures.toList()), - result.data.totalSignedHashes, + cardId = result.data.cardId, + signatures = signatures, + totalSignedHashes = result.data.totalSignedHashes, ) callback(CompletionResult.Success(finalResponse)) } else { sign(session, callback) } } + is CompletionResult.Failure -> callback(result) } } @@ -153,7 +158,7 @@ internal class SignCommand( val message = LocatorMessage( headerSource = LocatorMessage.Source( id = StringsLocator.ID.SIGN_MULTIPLE_CHUNKS_PART, - formatArgs = arrayOf(currentChunkNumber + 1, hashesChunked.size), + formatArgs = arrayOf(chunkHashesHelper.currentChunkIndex + 1, chunkHashesHelper.chunksCount), ), bodySource = null, ) @@ -171,21 +176,23 @@ internal class SignCommand( override fun serialize(environment: SessionEnvironment): CommandApdu { val walletIndex = environment.card?.wallet(walletPublicKey)?.index ?: throw TangemSdkError.WalletNotFound() - val dataToSign = hashesChunked[currentChunkNumber].reduce { arr1, arr2 -> arr1 + arr2 } - val hashSize = if (hashSizes > 255) hashSizes.toByteArray(2) else hashSizes.toByteArray(1) + val chunk = chunkHashesHelper.getCurrentChunk() + val hashSize = chunk.hashSize + val hashSizeData = if (hashSize > 255) hashSize.toByteArray(2) else hashSize.toByteArray(1) + val flattedHashes = chunk.hashes.map { it.data }.reduce { arr1, arr2 -> arr1 + arr2 } val tlvBuilder = TlvBuilder() tlvBuilder.append(TlvTag.Pin, environment.accessCode.value) tlvBuilder.append(TlvTag.Pin2, environment.passcode.value) tlvBuilder.append(TlvTag.CardId, environment.card?.cardId) - tlvBuilder.append(TlvTag.TransactionOutHashSize, hashSize) - tlvBuilder.append(TlvTag.TransactionOutHash, dataToSign) + tlvBuilder.append(TlvTag.TransactionOutHashSize, hashSizeData) + tlvBuilder.append(TlvTag.TransactionOutHash, flattedHashes) tlvBuilder.append(TlvTag.Cvc, environment.cvc) // Wallet index works only on COS v. 4.0 and higher. For previous version index will be ignored tlvBuilder.append(TlvTag.WalletIndex, walletIndex) terminalKeys?.let { - val signedData = dataToSign.sign(it.privateKey) + val signedData = flattedHashes.sign(it.privateKey) tlvBuilder.append(TlvTag.TerminalTransactionSignature, signedData) tlvBuilder.append(TlvTag.TerminalPublicKey, it.publicKey) } @@ -194,21 +201,35 @@ internal class SignCommand( return CommandApdu(Instruction.Sign, tlvBuilder.serialize()) } - override fun deserialize(environment: SessionEnvironment, apdu: ResponseApdu): SignResponse { + override fun deserialize(environment: SessionEnvironment, apdu: ResponseApdu): SignResponse.PartialSignResponse { val tlvData = deserializeApdu(environment, apdu) val decoder = TlvDecoder(tlvData) - val signature: ByteArray = decoder.decode(TlvTag.WalletSignature) - val splittedSignatures = splitSignedSignature(signature, getChunk().count()) - return SignResponse( - decoder.decode(TlvTag.CardId), - splittedSignatures, - decoder.decodeOptional(TlvTag.WalletSignedHashes), + val chunk = chunkHashesHelper.getCurrentChunk() + val signatureBLOB: ByteArray = decoder.decode(TlvTag.WalletSignature) + val signatures = splitSignatureBLOB(signatureBLOB, chunk.hashes.size) + + val signedHashes = chunk.hashes.zip(signatures).map { (hash, signature) -> + SignedHash( + index = hash.index, + data = hash.data, + signature = signature, + ) + } + + val signedChunk = SignedChunk(signedHashes) + + return SignResponse.PartialSignResponse( + cardId = decoder.decode(TlvTag.CardId), + signedChunk = signedChunk, + totalSignedHashes = decoder.decode(TlvTag.WalletSignedHashes), ) } - private fun processSignatures(environment: SessionEnvironment, signatures: List): List { + private fun processSignatures(environment: SessionEnvironment): List { + val signatures = chunkHashesHelper.getSignatures() + if (!environment.config.canonizeSecp256k1Signatures) return signatures val wallet = environment.card?.wallet(walletPublicKey) ?: return signatures if (wallet.curve != EllipticCurve.Secp256k1) return signatures @@ -216,13 +237,7 @@ internal class SignCommand( return signatures.map { CryptoUtils.normalize(it) } } - private fun getChunk(): IntRange { - val from = currentChunkNumber * chunkSize - val to = min(from + chunkSize, hashes.size) - return from until to - } - - private fun splitSignedSignature(signature: ByteArray, numberOfSignatures: Int): List { + private fun splitSignatureBLOB(signature: ByteArray, numberOfSignatures: Int): List { val signatures = mutableListOf() val signatureSize = signature.size / numberOfSignatures for (index in 0 until numberOfSignatures) { @@ -242,12 +257,4 @@ internal class SignCommand( return environment.terminalKeys } - - private companion object { - // The max answer is 1152 bytes (unencrypted) and 1120 (encrypted). - // The worst case is 8 hashes * 64 bytes for ed + 512 bytes of signatures + cardId, SignedHashes + TLV + SW is ok. - const val PACKAGE_SIZE = 512 - // Card limitation - const val MAX_CHUNK_SIZE = 10 - } } diff --git a/tangem-sdk-core/src/main/java/com/tangem/operations/sign/SignDTO.kt b/tangem-sdk-core/src/main/java/com/tangem/operations/sign/SignDTO.kt new file mode 100644 index 00000000..28c59c5c --- /dev/null +++ b/tangem-sdk-core/src/main/java/com/tangem/operations/sign/SignDTO.kt @@ -0,0 +1,21 @@ +package com.tangem.operations.sign + +data class Hash( + val index: Int, + val data: ByteArray, +) + +data class SignedHash( + val index: Int, + val data: ByteArray, + val signature: ByteArray, +) + +data class Chunk( + val hashSize: Int, + val hashes: List, +) + +data class SignedChunk( + val signedHashes: List, +) diff --git a/tangem-sdk-core/src/test/java/com/tangem/operations/ChunkHashesTests.kt b/tangem-sdk-core/src/test/java/com/tangem/operations/ChunkHashesTests.kt new file mode 100644 index 00000000..f8ba891d --- /dev/null +++ b/tangem-sdk-core/src/test/java/com/tangem/operations/ChunkHashesTests.kt @@ -0,0 +1,152 @@ +package com.tangem.operations + +import com.tangem.common.extensions.hexToBytes +import com.tangem.operations.sign.Chunk +import com.tangem.operations.sign.ChunkHashesUtils +import com.tangem.operations.sign.ChunkedHashesContainer +import com.tangem.operations.sign.Hash +import com.tangem.operations.sign.SignedChunk +import com.tangem.operations.sign.SignedHash +import org.junit.Test +import kotlin.test.assertEquals + +internal class ChunkHashesTests { + + @Test + fun testSingleHashChunk() { + val testData = listOf("f1642bb080e1f320924dde7238c1c5f8") + val hashes = testData.map { it.hexToBytes() }.toTypedArray() + + val chunks = ChunkHashesUtils.chunkHashes(hashes) + assertEquals(chunks.size, 1) + + val expectedChunk = Chunk(hashSize = 16, hashes = listOf(Hash(index = 0, data = hashes[0]))) + assertEquals(chunks, listOf(expectedChunk)) + } + + @Test + fun testMultipleHashesChunk() { + val testData = listOf( + "f1642bb080e1f320924dde7238c1c5f8", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f8", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f8", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f0", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f1", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f2", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f3", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f4", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f5", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f6", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f7", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f9", + "f1642bb080e1f320924dde7238c1c5f8", + "f1642bb080e1f320924dde7238c1c5f8aa", + "f1642bb080e1f320924dde7238c1c5f8ab", + ) + + val hashes = testData.map { it.hexToBytes() }.toTypedArray() + + val chunks = ChunkHashesUtils.chunkHashes(hashes) + assertEquals(chunks.size, 4) + + val expectedChunks = listOf( + Chunk( + hashSize = 16, + hashes = listOf( + Hash(index = 0, data = hashes[0]), + Hash(index = 12, data = hashes[12]), + ), + ), + Chunk( + hashSize = 17, + hashes = listOf( + Hash(index = 13, data = hashes[13]), + Hash(index = 14, data = hashes[14]), + ), + ), + Chunk( + hashSize = 32, + hashes = listOf( + Hash(index = 1, data = hashes[1]), + Hash(index = 2, data = hashes[2]), + Hash(index = 3, data = hashes[3]), + Hash(index = 4, data = hashes[4]), + Hash(index = 5, data = hashes[5]), + Hash(index = 6, data = hashes[6]), + Hash(index = 7, data = hashes[7]), + Hash(index = 8, data = hashes[8]), + Hash(index = 9, data = hashes[9]), + Hash(index = 10, data = hashes[10]), + ), + ), + Chunk( + hashSize = 32, + hashes = listOf( + Hash(index = 11, data = hashes[11]), + ), + ), + ) + + assertEquals(chunks.sortedBy { it.hashSize }, expectedChunks.sortedBy { it.hashSize }) + } + + @Test + fun testStrictSignaturesOrder() { + val testHashesData = listOf( + "f1642bb080e1f320924dde7238c1c5f8", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f8", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f8", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f0", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f1", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f2", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f3", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f4", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f5", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f6", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f7", + "f1642bb080e1f320924dde7238c1c5f8f1642bb080e1f320924dde7238c1c5f9", + "f1642bb080e1f320924dde7238c1c5f8", + "f1642bb080e1f320924dde7238c1c5f8aa", + "f1642bb080e1f320924dde7238c1c5f8ab", + ) + + val testSignaturesData = listOf( + "0001", + "0002", + "0003", + "0004", + "0005", + "0006", + "0007", + "0008", + "0009", + "0010", + "0011", + "0012", + "0013", + "0014", + "0015", + ) + + val hashes = testHashesData.map { it.hexToBytes() }.toTypedArray() + val expectedSignatures = testSignaturesData.map { it.hexToBytes() } + + val container = ChunkedHashesContainer(hashes = hashes) + + repeat(container.chunks.size) { + val chunk = container.getCurrentChunk() + + val signedHashes = chunk.hashes.map { + SignedHash( + index = it.index, + data = it.data, + signature = expectedSignatures[it.index], + ) + } + container.addSignedChunk(SignedChunk(signedHashes = signedHashes)) + } + + val signatures = container.getSignatures() + assertEquals(signatures, expectedSignatures) + } +}