From e795c935aa9e5413a02c63e668697d8ba911e69b Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Jun 2026 16:01:17 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 6 +- .../tap/di/domain/TransactionDomainModule.kt | 16 +- .../solana/WcSolanaMessageSignUseCaseTest.kt | 16 +- domain/address-book/build.gradle.kts | 1 + .../usecase/SignAddressEntriesUseCase.kt | 14 +- .../usecase/VerifyAddressEntriesUseCase.kt | 4 +- .../usecase/SignAddressEntriesUseCaseTest.kt | 44 +++-- .../VerifyAddressEntriesUseCaseTest.kt | 4 +- .../transaction/usecase/PrimaryPublicKey.kt | 7 +- .../transaction/usecase/SignHashesUseCase.kt | 62 ------- .../domain/transaction/usecase/SignUseCase.kt | 35 ++++ ...e.kt => VerifySecp256k1MessagesUseCase.kt} | 15 +- .../usecase/SignHashesUseCaseTest.kt | 131 --------------- .../transaction/usecase/SignUseCaseTest.kt | 153 ++++++++++++++++++ ... => VerifySecp256k1MessagesUseCaseTest.kt} | 4 +- 15 files changed, 270 insertions(+), 242 deletions(-) delete mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignHashesUseCase.kt rename domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/{VerifyMessagesUseCase.kt => VerifySecp256k1MessagesUseCase.kt} (74%) delete mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignHashesUseCaseTest.kt create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt rename domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/{VerifyMessagesUseCaseTest.kt => VerifySecp256k1MessagesUseCaseTest.kt} (97%) diff --git a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt index 7e75dddd09..ffb237ebe0 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt @@ -4,7 +4,7 @@ import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase import com.tangem.domain.addressbook.usecase.VerifyAddressEntriesUseCase import com.tangem.domain.tokens.GetNetworkAddressesUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase -import com.tangem.domain.transaction.usecase.VerifyMessagesUseCase +import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -29,7 +29,9 @@ object AddressBookDomainModule { @Provides @Singleton - fun provideVerifyAddressEntriesUseCase(verifyMessagesUseCase: VerifyMessagesUseCase): VerifyAddressEntriesUseCase { + fun provideVerifyAddressEntriesUseCase( + verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, + ): VerifyAddressEntriesUseCase { return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index b0590224a1..ad43e18a4a 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -237,20 +237,8 @@ internal object TransactionDomainModule { @Provides @Singleton - fun provideSignHashesUseCase( - cardSdkConfigRepository: CardSdkConfigRepository, - tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, - ): SignHashesUseCase { - return SignHashesUseCase( - cardSdkConfigRepository = cardSdkConfigRepository, - getHotTransactionSigner = { tangemHotWalletSignerFactory.create(it) }, - ) - } - - @Provides - @Singleton - fun provideVerifyMessagesUseCase(): VerifyMessagesUseCase { - return VerifyMessagesUseCase() + fun provideVerifySecp256k1MessagesUseCase(): VerifySecp256k1MessagesUseCase { + return VerifySecp256k1MessagesUseCase() } @Provides diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaMessageSignUseCaseTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaMessageSignUseCaseTest.kt index 1141ca8c9d..0b19ef0cde 100644 --- a/data/wallet-connect/src/test/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaMessageSignUseCaseTest.kt +++ b/data/wallet-connect/src/test/kotlin/com/tangem/data/walletconnect/network/solana/WcSolanaMessageSignUseCaseTest.kt @@ -83,7 +83,13 @@ internal class WcSolanaMessageSignUseCaseTest { assertTrue(result.isLeft()) assertTrue(result.leftOrNull() is WcRequestError.UnknownError) } - coVerify(exactly = 0) { signUseCase(any(), any(), any()) } + coVerify(exactly = 0) { + signUseCase( + hash = any(), + userWallet = any(), + network = any(), + ) + } coVerify(exactly = 0) { respondService.respond(any(), any()) } } @@ -92,7 +98,13 @@ internal class WcSolanaMessageSignUseCaseTest { runTest(UnconfinedTestDispatcher()) { // Arrange val message = "Sign in to Tangem\nNonce: 8f3a91c0d4".toByteArray() - coEvery { signUseCase(any(), any(), any()) } returns byteArrayOf(0x0A, 0x0B, 0x0C).right() + coEvery { + signUseCase( + hash = any(), + userWallet = any(), + network = any(), + ) + } returns byteArrayOf(0x0A, 0x0B, 0x0C).right() coEvery { respondService.respond(any(), any()) } returns RESPOND_RESULT.right() val useCase = createUseCase(rawMessage = message.encodeBase58()) diff --git a/domain/address-book/build.gradle.kts b/domain/address-book/build.gradle.kts index ff2feec39c..6121847a3c 100644 --- a/domain/address-book/build.gradle.kts +++ b/domain/address-book/build.gradle.kts @@ -23,5 +23,6 @@ dependencies { // region Test libraries testImplementation(projects.test.core) testImplementation(projects.test.mock) + testImplementation(projects.common.test) // endregion } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt index 3bcfdfe5e2..525745b768 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt @@ -6,25 +6,27 @@ import com.tangem.domain.addressbook.model.AddressEntry import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.error.SignHashesError -import com.tangem.domain.transaction.usecase.SignHashesUseCase +import com.tangem.domain.transaction.usecase.SignUseCase +import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey import com.tangem.utils.extensions.toHexString import java.security.MessageDigest /** - * Signs every [AddressEntry] of a [Contact] with the wallet's primary key in a single signing - * session (one card tap). Each entry is hashed as `SHA-256(address + networkId + memo + contactId + - * name)` and the produced signature is stored back into [AddressEntry.signature]. + * Signs every [AddressEntry] of a [Contact] with the wallet's primary secp256k1 key in a single + * signing session (one card tap). Each entry is hashed as `SHA-256(address + networkId + memo + + * contactId + name)` and the produced signature is stored back into [AddressEntry.signature]. */ class SignAddressEntriesUseCase( - private val signHashesUseCase: SignHashesUseCase, + private val signUseCase: SignUseCase, ) { suspend operator fun invoke(userWallet: UserWallet, contact: Contact): Either = either { val entries = contact.addressEntries if (entries.isEmpty()) return@either contact + val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey) val hashes = entries.map { entry -> hashEntry(contact, entry) } - val signatures = signHashesUseCase(userWallet = userWallet, hashes = hashes).bind() + val signatures = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind() val signedEntries = entries.mapIndexed { index, entry -> entry.copy(signature = signatures[index].toHexString()) diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt index 3ac4f4dd28..d82ecd9ee4 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt @@ -7,7 +7,7 @@ import com.tangem.domain.addressbook.model.AddressEntry import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.error.VerifyMessagesError -import com.tangem.domain.transaction.usecase.VerifyMessagesUseCase +import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase import com.tangem.utils.extensions.hexToBytesOrNull /** @@ -23,7 +23,7 @@ import com.tangem.utils.extensions.hexToBytesOrNull * Each entry is verified against the exact bytes that were signed (see [buildAddressEntryPayload]). */ class VerifyAddressEntriesUseCase( - private val verifyMessagesUseCase: VerifyMessagesUseCase, + private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, ) { operator fun invoke( diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt index f15ab54433..9a4405a9f4 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt @@ -3,6 +3,7 @@ package com.tangem.domain.addressbook.usecase import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.wallet.MockUserWalletFactory import com.tangem.domain.addressbook.model.AddressEntry import com.tangem.domain.addressbook.model.AddressEntryId import com.tangem.domain.addressbook.model.Contact @@ -12,11 +13,12 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.error.SignHashesError -import com.tangem.domain.transaction.usecase.SignHashesUseCase +import com.tangem.domain.transaction.usecase.SignUseCase import com.tangem.utils.extensions.toHexString import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.test.runTest @@ -28,14 +30,16 @@ import java.security.MessageDigest @TestInstance(TestInstance.Lifecycle.PER_CLASS) class SignAddressEntriesUseCaseTest { - private val signHashesUseCase: SignHashesUseCase = mockk() - private val useCase = SignAddressEntriesUseCase(signHashesUseCase = signHashesUseCase) + private val signUseCase: SignUseCase = mockk() + private val useCase = SignAddressEntriesUseCase(signUseCase = signUseCase) - private val userWallet: UserWallet = mockk() + // The mock factory builds each wallet key with publicKey = curve.name bytes, so the secp256k1 key is "Secp256k1" + private val userWallet: UserWallet = MockUserWalletFactory.create() + private val secp256k1Key = "Secp256k1".toByteArray() @BeforeEach fun resetMocks() { - clearMocks(signHashesUseCase) + clearMocks(signUseCase) } @Test @@ -47,7 +51,10 @@ class SignAddressEntriesUseCaseTest { ) val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte())) val hashesSlot = slot>() - coEvery { signHashesUseCase(eq(userWallet), capture(hashesSlot)) } returns signatures.right() + val publicKeySlot = slot() + coEvery { + signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet)) + } returns signatures.right() // Act val result = useCase(userWallet, contact) @@ -61,6 +68,8 @@ class SignAddressEntriesUseCaseTest { ), ) assertThat(result.getOrNull()).isEqualTo(expected) + // The wallet's primary secp256k1 key is the one signing + assertThat(publicKeySlot.captured).isEqualTo(secp256k1Key) // Each entry is hashed as SHA-256(address + networkId + memo + contactId + name), in order assertThat(hashesSlot.captured.map { it.toHexString() }) .containsExactly( @@ -80,20 +89,35 @@ class SignAddressEntriesUseCaseTest { // Assert assertThat(result.getOrNull()).isEqualTo(contact) - coVerify(exactly = 0) { signHashesUseCase(any(), any()) } + coVerify(exactly = 0) { signUseCase(any>(), any(), any()) } } @Test - fun `GIVEN signHashesUseCase returns error WHEN invoke THEN propagates the error`() = runTest { + fun `GIVEN wallet without a secp256k1 key WHEN invoke THEN returns NoSigningKey without signing`() = runTest { + // Arrange — a locked hot wallet exposes no key + val lockedWallet = mockk { every { wallets } returns null } + val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null)) + + // Act + val result = useCase(lockedWallet, contact) + + // Assert + assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey) + coVerify(exactly = 0) { signUseCase(any>(), any(), any()) } + } + + @Test + fun `GIVEN signUseCase returns error WHEN invoke THEN propagates the error`() = runTest { // Arrange val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null)) - coEvery { signHashesUseCase(any(), any()) } returns SignHashesError.NoSigningKey.left() + coEvery { signUseCase(any>(), any(), any()) } returns + SignHashesError.SigningFailed(message = "canceled").left() // Act val result = useCase(userWallet, contact) // Assert - assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey) + assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "canceled")) } private fun contact(vararg entries: AddressEntry): Contact = Contact( diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt index 709fff615b..88501ea467 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.error.VerifyMessagesError -import com.tangem.domain.transaction.usecase.VerifyMessagesUseCase +import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase import com.tangem.utils.extensions.toHexString import io.mockk.clearMocks import io.mockk.every @@ -26,7 +26,7 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class VerifyAddressEntriesUseCaseTest { - private val verifyMessagesUseCase: VerifyMessagesUseCase = mockk() + private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase = mockk() private val useCase = VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) private val userWallet: UserWallet = mockk() diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrimaryPublicKey.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrimaryPublicKey.kt index 6cb02f2aaa..f2e2e7b6db 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrimaryPublicKey.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrimaryPublicKey.kt @@ -7,10 +7,11 @@ import com.tangem.domain.models.wallet.UserWallet * Wallet master secp256k1 public key bytes, without any network derivation. Returns `null` when the * wallet is locked or has no secp256k1 key. * - * This is the single source of truth for the key used to sign ([SignHashesUseCase]) and verify - * ([VerifyMessagesUseCase]) raw hashes, so both operations resolve to the very same key. + * This is the single source of truth for the key a caller hands to [SignUseCase] and the key + * [VerifySecp256k1MessagesUseCase] verifies against, so both operations resolve to the very same key. + * The curve stays encapsulated here (callers receive plain bytes) so they don't depend on the card SDK. */ -internal fun UserWallet.primarySecp256k1PublicKey(): ByteArray? = when (this) { +fun UserWallet.primarySecp256k1PublicKey(): ByteArray? = when (this) { is UserWallet.Cold -> scanResponse.card.wallets .firstOrNull { it.curve == EllipticCurve.Secp256k1 } ?.publicKey diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignHashesUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignHashesUseCase.kt deleted file mode 100644 index 4e7ac3d8a0..0000000000 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignHashesUseCase.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.tangem.domain.transaction.usecase - -import arrow.core.Either -import arrow.core.left -import arrow.core.right -import com.tangem.blockchain.common.TransactionSigner -import com.tangem.blockchain.common.Wallet -import com.tangem.common.CompletionResult -import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins -import com.tangem.domain.card.models.TwinKey -import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.transaction.error.SignHashesError - -/** - * Signs a batch of raw [hashes] with the wallet's primary secp256k1 key in a single signing - * session — one NFC tap for cold cards, one access-code unlock for hot wallets. - * - * The hashes are signed with the wallet master key without any network derivation, so every - * signature verifies against that single wallet public key regardless of which networks the hashed - * data refers to. Use it when several pieces of data must be attested with the same wallet identity - * in one user interaction (e.g. signing all address-book entries of a contact at once). - * - * Signatures are returned in the same order as the input [hashes]. - */ -class SignHashesUseCase( - private val cardSdkConfigRepository: CardSdkConfigRepository, - private val getHotTransactionSigner: (UserWallet.Hot) -> TransactionSigner, -) { - - suspend operator fun invoke( - userWallet: UserWallet, - hashes: List, - ): Either> { - if (hashes.isEmpty()) return emptyList().right() - - val seedKey = userWallet.primarySecp256k1PublicKey() ?: return SignHashesError.NoSigningKey.left() - val publicKey = Wallet.PublicKey(seedKey = seedKey, derivationType = null) - - val signer = when (userWallet) { - is UserWallet.Hot -> getHotTransactionSigner(userWallet) - is UserWallet.Cold -> getColdSigner(userWallet) - } - - return when (val result = signer.sign(hashes, publicKey)) { - is CompletionResult.Success -> result.data.right() - is CompletionResult.Failure -> SignHashesError.SigningFailed( - message = result.error.message ?: "Unknown error", - ).left() - } - } - - private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner { - val card = userWallet.scanResponse.card - val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins - - return cardSdkConfigRepository.getCommonSigner( - cardId = card.cardId.takeIf { isCardNotBackedUp }, - twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse), - ) - } -} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt index 07b302c99c..403812743d 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt @@ -4,12 +4,14 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import com.tangem.blockchain.common.TransactionSigner +import com.tangem.blockchain.common.Wallet import com.tangem.common.CompletionResult import com.tangem.common.core.TangemError import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins import com.tangem.domain.card.models.TwinKey import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.models.network.Network +import com.tangem.domain.transaction.error.SignHashesError import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.models.wallet.UserWallet @@ -37,6 +39,39 @@ class SignUseCase( } } + /** + * Signs a batch of raw [hashes] with [publicKey] in a single signing session — one NFC tap for + * cold cards, one access-code unlock for hot wallets. + * + * [publicKey] is the wallet seed key the hashes are signed with, without any network derivation, + * so every signature verifies against that single public key regardless of which networks the + * hashed data refers to. Resolving which key to sign with (and its curve) is the caller's + * responsibility — this use case is curve-agnostic and signs exactly the given hashes. + * + * Signatures are returned in the same order as the input [hashes]; an empty input yields an empty + * list without starting a signing session. + */ + suspend operator fun invoke( + hashes: List, + publicKey: ByteArray, + userWallet: UserWallet, + ): Either> { + if (hashes.isEmpty()) return emptyList().right() + + val signer = when (userWallet) { + is UserWallet.Hot -> getHotTransactionSigner(userWallet) + is UserWallet.Cold -> getColdSigner(userWallet) + } + val seedPublicKey = Wallet.PublicKey(seedKey = publicKey, derivationType = null) + + return when (val result = signer.sign(hashes, seedPublicKey)) { + is CompletionResult.Success -> result.data.right() + is CompletionResult.Failure -> SignHashesError.SigningFailed( + message = result.error.message ?: "Unknown error", + ).left() + } + } + private fun getColdSigner(userWallet: UserWallet.Cold): TransactionSigner { val card = userWallet.scanResponse.card val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifySecp256k1MessagesUseCase.kt similarity index 74% rename from domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCase.kt rename to domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifySecp256k1MessagesUseCase.kt index c395413494..d2b8b190cd 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifySecp256k1MessagesUseCase.kt @@ -10,12 +10,15 @@ import com.tangem.domain.transaction.error.VerifyMessagesError /** * Verifies each of the given [messages] against [userWallet]'s primary secp256k1 key — the - * counterpart of [SignHashesUseCase]. + * counterpart of [SignUseCase] for raw-hash signatures. * - * Pass the **original messages** (the pre-images), not their hashes: signing hashes a message with - * SHA-256 before the elliptic-curve operation, so verification applies the same SHA-256 internally - * (via [CryptoUtils.verify]). [messages] and [signatures] are positional — element `i` of one must - * correspond to element `i` of the other. + * Only secp256k1 is supported: the verifier hashes the message with SHA-256 and runs ECDSA, which + * mirrors how the card signs (`SHA-256(message)` then ECDSA). Other curves use a different hashing + * scheme, so this use case is deliberately curve-specific rather than parameterized. + * + * Pass the **original messages** (the pre-images), not their hashes: the SHA-256 is applied + * internally (via [CryptoUtils.verify]). [messages] and [signatures] are positional — element `i` of + * one must correspond to element `i` of the other. * * Returns one [Boolean] per message, aligned to [messages] order: `result[i]` is `true` only when * `signatures[i]` is a valid signature of `messages[i]`. A mismatch (tampered data, wrong wallet, @@ -23,7 +26,7 @@ import com.tangem.domain.transaction.error.VerifyMessagesError * wallet's signing key being unavailable is a [VerifyMessagesError.NoSigningKey] failure (nothing can * be verified) rather than a list of `false`s. */ -class VerifyMessagesUseCase { +class VerifySecp256k1MessagesUseCase { operator fun invoke( userWallet: UserWallet, diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignHashesUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignHashesUseCaseTest.kt deleted file mode 100644 index a7f6dd8f7d..0000000000 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignHashesUseCaseTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.tangem.domain.transaction.usecase - -import com.google.common.truth.Truth.assertThat -import com.tangem.blockchain.common.TransactionSigner -import com.tangem.blockchain.common.Wallet -import com.tangem.common.CompletionResult -import com.tangem.common.card.EllipticCurve -import com.tangem.common.core.TangemError -import com.tangem.common.test.domain.wallet.MockUserWalletFactory -import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.domain.models.MobileWallet -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.transaction.error.SignHashesError -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Test - -internal class SignHashesUseCaseTest { - - private val cardSdkConfigRepository: CardSdkConfigRepository = mockk() - private val getHotTransactionSigner: (UserWallet.Hot) -> TransactionSigner = mockk() - - private val useCase = SignHashesUseCase( - cardSdkConfigRepository = cardSdkConfigRepository, - getHotTransactionSigner = getHotTransactionSigner, - ) - - private val hashes = listOf(byteArrayOf(1, 2, 3), byteArrayOf(4, 5, 6)) - private val signatures = listOf(byteArrayOf(7, 8, 9), byteArrayOf(10, 11, 12)) - - @Test - fun `GIVEN cold wallet with secp256k1 key WHEN invoke THEN signs hashes with common signer`() = runTest { - // Arrange - val coldWallet = MockUserWalletFactory.create() - val signer: TransactionSigner = mockk() - val publicKeySlot = slot() - - every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer - coEvery { signer.sign(eq(hashes), capture(publicKeySlot)) } returns CompletionResult.Success(signatures) - - // Act - val result = useCase(coldWallet, hashes) - - // Assert - assertThat(result.getOrNull()).isEqualTo(signatures) - // Wallet master secp256k1 key is used, without network derivation - assertThat(publicKeySlot.captured.seedKey).isEqualTo(EllipticCurve.Secp256k1.name.toByteArray()) - assertThat(publicKeySlot.captured.derivationType).isNull() - // Card is not backed up (backupStatus == null) and not a twin, so its id is passed to the signer - verify(exactly = 1) { cardSdkConfigRepository.getCommonSigner(cardId = coldWallet.cardId, twinKey = null) } - } - - @Test - fun `GIVEN hot wallet with secp256k1 key WHEN invoke THEN signs hashes with hot signer`() = runTest { - // Arrange - val hotWallet = mockk { - every { wallets } returns listOf(mobileWallet(curve = EllipticCurve.Secp256k1, publicKey = byteArrayOf(42))) - } - val signer: TransactionSigner = mockk() - val publicKeySlot = slot() - - every { getHotTransactionSigner(hotWallet) } returns signer - coEvery { signer.sign(eq(hashes), capture(publicKeySlot)) } returns CompletionResult.Success(signatures) - - // Act - val result = useCase(hotWallet, hashes) - - // Assert - assertThat(result.getOrNull()).isEqualTo(signatures) - assertThat(publicKeySlot.captured.seedKey).isEqualTo(byteArrayOf(42)) - assertThat(publicKeySlot.captured.derivationType).isNull() - verify(exactly = 1) { getHotTransactionSigner(hotWallet) } - } - - @Test - fun `GIVEN locked wallet without signing key WHEN invoke THEN returns NoSigningKey`() = runTest { - // Arrange - val lockedWallet = mockk { - every { wallets } returns null - } - - // Act - val result = useCase(lockedWallet, hashes) - - // Assert - assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey) - verify(exactly = 0) { getHotTransactionSigner(any()) } - } - - @Test - fun `GIVEN signer fails WHEN invoke THEN returns SigningFailed with error message`() = runTest { - // Arrange - val coldWallet = MockUserWalletFactory.create() - val signer: TransactionSigner = mockk() - val error: TangemError = mockk { every { message } returns "Signing canceled" } - - every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer - coEvery { signer.sign(any>(), any()) } returns CompletionResult.Failure(error) - - // Act - val result = useCase(coldWallet, hashes) - - // Assert - assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "Signing canceled")) - } - - @Test - fun `GIVEN empty hashes WHEN invoke THEN returns empty list without signing`() = runTest { - // Arrange - val coldWallet = MockUserWalletFactory.create() - - // Act - val result = useCase(coldWallet, hashes = emptyList()) - - // Assert - assertThat(result.getOrNull()).isEmpty() - verify(exactly = 0) { cardSdkConfigRepository.getCommonSigner(any(), any()) } - verify(exactly = 0) { getHotTransactionSigner(any()) } - } - - private fun mobileWallet(curve: EllipticCurve, publicKey: ByteArray): MobileWallet = MobileWallet( - publicKey = publicKey, - chainCode = null, - curve = curve, - derivedKeys = emptyMap(), - ) -} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt new file mode 100644 index 0000000000..4621315e38 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignUseCaseTest.kt @@ -0,0 +1,153 @@ +package com.tangem.domain.transaction.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.TransactionSigner +import com.tangem.blockchain.common.Wallet +import com.tangem.blockchain.common.WalletManager +import com.tangem.common.CompletionResult +import com.tangem.common.core.TangemError +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.error.SignHashesError +import com.tangem.domain.walletmanager.WalletManagersFacade +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class SignUseCaseTest { + + private val cardSdkConfigRepository: CardSdkConfigRepository = mockk() + private val walletManagersFacade: WalletManagersFacade = mockk() + private val getHotTransactionSigner: (UserWallet.Hot) -> TransactionSigner = mockk() + + private val useCase = SignUseCase( + cardSdkConfigRepository = cardSdkConfigRepository, + walletManagersFacade = walletManagersFacade, + getHotTransactionSigner = getHotTransactionSigner, + ) + + private val publicKey = byteArrayOf(42, 43, 44) + private val hashes = listOf(byteArrayOf(1, 2, 3), byteArrayOf(4, 5, 6)) + private val signatures = listOf(byteArrayOf(7, 8, 9), byteArrayOf(10, 11, 12)) + + @Test + fun `GIVEN cold wallet WHEN sign single hash THEN signs with the wallet-manager key for the network`() = runTest { + // Arrange + val coldWallet = MockUserWalletFactory.create() + val network: Network = mockk() + val signer: TransactionSigner = mockk() + val walletManagerKey = Wallet.PublicKey(seedKey = byteArrayOf(50, 51), derivationType = null) + val walletManager: WalletManager = mockk { every { wallet } returns mockk { every { publicKey } returns walletManagerKey } } + val hash = byteArrayOf(1, 2, 3) + val signature = byteArrayOf(9, 9) + + every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer + coEvery { walletManagersFacade.getOrCreateWalletManager(coldWallet.walletId, network) } returns walletManager + coEvery { signer.sign(eq(hash), eq(walletManagerKey)) } returns CompletionResult.Success(signature) + + // Act + val result = useCase(hash = hash, userWallet = coldWallet, network = network) + + // Assert + assertThat(result.getOrNull()).isEqualTo(signature) + } + + @Test + fun `GIVEN signer fails WHEN sign single hash THEN returns the TangemError`() = runTest { + // Arrange + val coldWallet = MockUserWalletFactory.create() + val network: Network = mockk() + val signer: TransactionSigner = mockk() + val walletManagerKey = Wallet.PublicKey(seedKey = byteArrayOf(50, 51), derivationType = null) + val walletManager: WalletManager = mockk { every { wallet } returns mockk { every { publicKey } returns walletManagerKey } } + val error: TangemError = mockk() + + every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer + coEvery { walletManagersFacade.getOrCreateWalletManager(coldWallet.walletId, network) } returns walletManager + coEvery { signer.sign(any(), any()) } returns CompletionResult.Failure(error) + + // Act + val result = useCase(hash = byteArrayOf(1), userWallet = coldWallet, network = network) + + // Assert + assertThat(result.leftOrNull()).isEqualTo(error) + } + + @Test + fun `GIVEN cold wallet WHEN sign hashes THEN signs with common signer and given key`() = runTest { + // Arrange + val coldWallet = MockUserWalletFactory.create() + val signer: TransactionSigner = mockk() + val publicKeySlot = slot() + + every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer + coEvery { signer.sign(eq(hashes), capture(publicKeySlot)) } returns CompletionResult.Success(signatures) + + // Act + val result = useCase(hashes = hashes, publicKey = publicKey, userWallet = coldWallet) + + // Assert + assertThat(result.getOrNull()).isEqualTo(signatures) + // The caller-provided key is used verbatim, with no network derivation + assertThat(publicKeySlot.captured.seedKey).isEqualTo(publicKey) + assertThat(publicKeySlot.captured.derivationType).isNull() + // Card is not backed up (backupStatus == null) and not a twin, so its id is passed to the signer + verify(exactly = 1) { cardSdkConfigRepository.getCommonSigner(cardId = coldWallet.cardId, twinKey = null) } + } + + @Test + fun `GIVEN hot wallet WHEN sign hashes THEN signs with hot signer and given key`() = runTest { + // Arrange + val hotWallet = mockk() + val signer: TransactionSigner = mockk() + val publicKeySlot = slot() + + every { getHotTransactionSigner(hotWallet) } returns signer + coEvery { signer.sign(eq(hashes), capture(publicKeySlot)) } returns CompletionResult.Success(signatures) + + // Act + val result = useCase(hashes = hashes, publicKey = publicKey, userWallet = hotWallet) + + // Assert + assertThat(result.getOrNull()).isEqualTo(signatures) + assertThat(publicKeySlot.captured.seedKey).isEqualTo(publicKey) + verify(exactly = 1) { getHotTransactionSigner(hotWallet) } + } + + @Test + fun `GIVEN signer fails WHEN sign hashes THEN returns SigningFailed with error message`() = runTest { + // Arrange + val coldWallet = MockUserWalletFactory.create() + val signer: TransactionSigner = mockk() + val error: TangemError = mockk { every { message } returns "Signing canceled" } + + every { cardSdkConfigRepository.getCommonSigner(any(), any()) } returns signer + coEvery { signer.sign(any>(), any()) } returns CompletionResult.Failure(error) + + // Act + val result = useCase(hashes = hashes, publicKey = publicKey, userWallet = coldWallet) + + // Assert + assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "Signing canceled")) + } + + @Test + fun `GIVEN empty hashes WHEN sign hashes THEN returns empty list without signing`() = runTest { + // Arrange + val coldWallet = MockUserWalletFactory.create() + + // Act + val result = useCase(hashes = emptyList(), publicKey = publicKey, userWallet = coldWallet) + + // Assert + assertThat(result.getOrNull()).isEmpty() + verify(exactly = 0) { cardSdkConfigRepository.getCommonSigner(any(), any()) } + verify(exactly = 0) { getHotTransactionSigner(any()) } + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/VerifySecp256k1MessagesUseCaseTest.kt similarity index 97% rename from domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCaseTest.kt rename to domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/VerifySecp256k1MessagesUseCaseTest.kt index 6bf5425e3c..08837fe5df 100644 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCaseTest.kt +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/VerifySecp256k1MessagesUseCaseTest.kt @@ -16,9 +16,9 @@ import org.junit.jupiter.api.TestInstance import java.security.MessageDigest @TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal class VerifyMessagesUseCaseTest { +internal class VerifySecp256k1MessagesUseCaseTest { - private val useCase = VerifyMessagesUseCase() + private val useCase = VerifySecp256k1MessagesUseCase() // A valid secp256k1 key pair. The card signs the raw SHA-256 digest of each message. private val privateKey = "FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632550".hexToBytes()