From fc24acdf8a9cbf71dc922aab490c689112832623 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 12 Jun 2026 14:34:23 +0100 Subject: [PATCH 1/3] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 8 + .../tap/di/domain/TransactionDomainModule.kt | 18 ++ .../model/AddressEntriesVerification.kt | 15 ++ .../usecase/AddressEntrySigningPayload.kt | 22 +++ .../usecase/SignAddressEntriesUseCase.kt | 39 ++++ .../usecase/VerifyAddressEntriesUseCase.kt | 55 ++++++ .../usecase/SignAddressEntriesUseCaseTest.kt | 119 +++++++++++++ .../VerifyAddressEntriesUseCaseTest.kt | 168 ++++++++++++++++++ .../transaction/error/SignHashesError.kt | 10 ++ .../transaction/error/VerifyMessagesError.kt | 7 + .../transaction/usecase/PrimaryPublicKey.kt | 20 +++ .../transaction/usecase/SignHashesUseCase.kt | 62 +++++++ .../usecase/VerifyMessagesUseCase.kt | 47 +++++ .../usecase/SignHashesUseCaseTest.kt | 131 ++++++++++++++ .../usecase/VerifyMessagesUseCaseTest.kt | 120 +++++++++++++ 15 files changed, 841 insertions(+) create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntriesVerification.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/error/SignHashesError.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/error/VerifyMessagesError.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrimaryPublicKey.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignHashesUseCase.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCase.kt create 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/VerifyMessagesUseCaseTest.kt 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 a06a3e002a..7e75dddd09 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 @@ -1,8 +1,10 @@ package com.tangem.tap.di.domain 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 dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,4 +26,10 @@ object AddressBookDomainModule { getNetworkAddressesUseCase = getNetworkAddressesUseCase, ) } + + @Provides + @Singleton + fun provideVerifyAddressEntriesUseCase(verifyMessagesUseCase: VerifyMessagesUseCase): 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 d88224fe30..b0590224a1 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 @@ -235,6 +235,24 @@ 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() + } + @Provides @Singleton fun provideCreateNFTTransferTransactionUseCase( diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntriesVerification.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntriesVerification.kt new file mode 100644 index 0000000000..65ab2749e9 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntriesVerification.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.addressbook.model + +/** + * Outcome of verifying a [Contact]'s [AddressEntry]s against the wallet that signed them. + * + * @property valid entries whose signature was produced by the wallet — these should be shown. + * @property invalid entries that failed verification (tampered, signed by another wallet, or carrying + * a missing/malformed signature) — these should be hidden. + */ +data class AddressEntriesVerification( + val valid: List, + val invalid: List, +) { + val areAllInvalid: Boolean get() = valid.isEmpty() && invalid.isNotEmpty() +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt new file mode 100644 index 0000000000..4a2cde23e9 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.addressbook.usecase + +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.Contact + +/** + * Builds the canonical bytes that are signed for a single [AddressEntry]: + * `address + networkId + memo + contactId + name`. + * + * Shared by [SignAddressEntriesUseCase] (which hashes and signs it) and [VerifyAddressEntriesUseCase] + * (which verifies the signature against it), so the signed and verified payloads can never diverge. + */ +internal fun buildAddressEntryPayload(contact: Contact, entry: AddressEntry): ByteArray { + val payload = buildString { + append(entry.address) + append(entry.networkId.value) + append(entry.memo.orEmpty()) + append(contact.id.value) + append(contact.name.value) + } + return payload.toByteArray(Charsets.UTF_8) +} \ 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 new file mode 100644 index 0000000000..3bcfdfe5e2 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt @@ -0,0 +1,39 @@ +package com.tangem.domain.addressbook.usecase + +import arrow.core.Either +import arrow.core.raise.either +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.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]. + */ +class SignAddressEntriesUseCase( + private val signHashesUseCase: SignHashesUseCase, +) { + + suspend operator fun invoke(userWallet: UserWallet, contact: Contact): Either = either { + val entries = contact.addressEntries + if (entries.isEmpty()) return@either contact + + val hashes = entries.map { entry -> hashEntry(contact, entry) } + val signatures = signHashesUseCase(userWallet = userWallet, hashes = hashes).bind() + + val signedEntries = entries.mapIndexed { index, entry -> + entry.copy(signature = signatures[index].toHexString()) + } + contact.copy(addressEntries = signedEntries) + } + + private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray { + val payload = buildAddressEntryPayload(contact, entry) + return MessageDigest.getInstance("SHA-256").digest(payload) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..3ac4f4dd28 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt @@ -0,0 +1,55 @@ +package com.tangem.domain.addressbook.usecase + +import arrow.core.Either +import arrow.core.right +import com.tangem.domain.addressbook.model.AddressEntriesVerification +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.utils.extensions.hexToBytesOrNull + +/** + * Verifies each [AddressEntry] of a [Contact] against [userWallet] and partitions them into the ones + * whose signature was produced by that wallet ([AddressEntriesVerification.valid]) and the ones that + * were not ([AddressEntriesVerification.invalid]). The counterpart of [SignAddressEntriesUseCase]. + * + * An entry is **invalid** when its signature fails verification or is missing/malformed (non-hex); + * such entries should be hidden from the user. Both partitions preserve the contact's original entry + * order. An empty contact yields two empty lists. The wallet's signing key being unavailable surfaces + * as a [VerifyMessagesError.NoSigningKey] failure (the entries cannot be verified at all). + * + * Each entry is verified against the exact bytes that were signed (see [buildAddressEntryPayload]). + */ +class VerifyAddressEntriesUseCase( + private val verifyMessagesUseCase: VerifyMessagesUseCase, +) { + + operator fun invoke( + userWallet: UserWallet, + contact: Contact, + ): Either { + val entries = contact.addressEntries + if (entries.isEmpty()) return AddressEntriesVerification(valid = emptyList(), invalid = emptyList()).right() + + // Entries with a malformed (non-hex) signature can't be verified — they are invalid by format. + val wellFormed = entries.mapNotNull { entry -> + entry.signature.hexToBytesOrNull()?.let { signature -> entry to signature } + } + val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) } + val signatures = wellFormed.map { (_, signature) -> signature } + + return verifyMessagesUseCase(userWallet = userWallet, messages = messages, signatures = signatures) + .map { flags -> + val validIds = wellFormed + .filterIndexed { index, _ -> flags[index] } + .mapTo(HashSet()) { (entry, _) -> entry.id } + + AddressEntriesVerification( + valid = entries.filter { it.id in validIds }, + invalid = entries.filterNot { it.id in validIds }, + ) + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..f15ab54433 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt @@ -0,0 +1,119 @@ +package com.tangem.domain.addressbook.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.addressbook.model.ContactName +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.utils.extensions.toHexString +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.security.MessageDigest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SignAddressEntriesUseCaseTest { + + private val signHashesUseCase: SignHashesUseCase = mockk() + private val useCase = SignAddressEntriesUseCase(signHashesUseCase = signHashesUseCase) + + private val userWallet: UserWallet = mockk() + + @BeforeEach + fun resetMocks() { + clearMocks(signHashesUseCase) + } + + @Test + fun `GIVEN contact with entries WHEN invoke THEN every entry receives its signature`() = runTest { + // Arrange + val contact = contact( + entry(id = "addr-1", address = "0xabc", memo = "memo"), + entry(id = "addr-2", address = "0xdef", memo = null), + ) + val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte())) + val hashesSlot = slot>() + coEvery { signHashesUseCase(eq(userWallet), capture(hashesSlot)) } returns signatures.right() + + // Act + val result = useCase(userWallet, contact) + + // Assert + // Signatures are applied in entry order, hex-encoded; all other fields are preserved + val expected = contact.copy( + addressEntries = listOf( + contact.addressEntries[0].copy(signature = signatures[0].toHexString()), + contact.addressEntries[1].copy(signature = signatures[1].toHexString()), + ), + ) + assertThat(result.getOrNull()).isEqualTo(expected) + // Each entry is hashed as SHA-256(address + networkId + memo + contactId + name), in order + assertThat(hashesSlot.captured.map { it.toHexString() }) + .containsExactly( + expectedHash(contact, contact.addressEntries[0]).toHexString(), + expectedHash(contact, contact.addressEntries[1]).toHexString(), + ) + .inOrder() + } + + @Test + fun `GIVEN contact with no entries WHEN invoke THEN returns contact unchanged without signing`() = runTest { + // Arrange + val contact = contact() + + // Act + val result = useCase(userWallet, contact) + + // Assert + assertThat(result.getOrNull()).isEqualTo(contact) + coVerify(exactly = 0) { signHashesUseCase(any(), any()) } + } + + @Test + fun `GIVEN signHashesUseCase 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() + + // Act + val result = useCase(userWallet, contact) + + // Assert + assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey) + } + + private fun contact(vararg entries: AddressEntry): Contact = Contact( + id = ContactId("contact-1"), + walletId = UserWalletId("011"), + name = requireNotNull(ContactName("Alice").getOrNull()), + addressEntries = entries.toList(), + ) + + private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry( + id = AddressEntryId(id), + address = address, + networkId = Network.RawID("ethereum"), + memo = memo, + signature = "", + ) + + private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray { + val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() + + contact.id.value + contact.name.value + return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..709fff615b --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt @@ -0,0 +1,168 @@ +package com.tangem.domain.addressbook.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.addressbook.model.ContactName +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.utils.extensions.toHexString +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class VerifyAddressEntriesUseCaseTest { + + private val verifyMessagesUseCase: VerifyMessagesUseCase = mockk() + private val useCase = VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) + + private val userWallet: UserWallet = mockk() + + @BeforeEach + fun resetMocks() { + clearMocks(verifyMessagesUseCase) + } + + @Test + fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() { + // Arrange + val contact = contact( + entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"), + entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"), + ) + val messagesSlot = slot>() + val signaturesSlot = slot>() + every { + verifyMessagesUseCase(eq(userWallet), capture(messagesSlot), capture(signaturesSlot)) + } returns listOf(true, true).right() + + // Act + val result = useCase(userWallet, contact) + + // Assert + // Each entry is verified against address + networkId + memo + contactId + name + assertThat(messagesSlot.captured.map { String(it) }) + .containsExactly( + expectedPayload(contact, contact.addressEntries[0]), + expectedPayload(contact, contact.addressEntries[1]), + ) + .inOrder() + // Hex signatures are decoded to bytes, in entry order + assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder() + } + + @Test + fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() { + // Arrange + val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB") + val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD") + val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF") + val contact = contact(valid1, invalid, valid2) + every { verifyMessagesUseCase(any(), any(), any()) } returns listOf(true, false, true).right() + + // Act + val result = useCase(userWallet, contact).getOrNull() + + // Assert + assertThat(result!!.valid).containsExactly(valid1, valid2).inOrder() + assertThat(result.invalid).containsExactly(invalid) + assertThat(result.areAllInvalid).isFalse() + } + + @Test + fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() { + // Arrange + val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex") + val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB") + val contact = contact(malformed, signed) + val signaturesSlot = slot>() + every { + verifyMessagesUseCase(eq(userWallet), any(), capture(signaturesSlot)) + } returns listOf(true).right() + + // Act + val result = useCase(userWallet, contact).getOrNull() + + // Assert + // Only the well-formed entry is passed to verification + assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB") + assertThat(result!!.valid).containsExactly(signed) + assertThat(result.invalid).containsExactly(malformed) + } + + @Test + fun `GIVEN every entry is invalid WHEN invoke THEN allInvalid is true`() { + // Arrange + val entry1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB") + val entry2 = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD") + val contact = contact(entry1, entry2) + every { verifyMessagesUseCase(any(), any(), any()) } returns listOf(false, false).right() + + // Act + val result = useCase(userWallet, contact).getOrNull() + + // Assert + assertThat(result!!.valid).isEmpty() + assertThat(result.invalid).containsExactly(entry1, entry2).inOrder() + assertThat(result.areAllInvalid).isTrue() + } + + @Test + fun `GIVEN contact with no entries WHEN invoke THEN returns empty partition without verifying`() { + // Arrange + val contact = contact() + + // Act + val result = useCase(userWallet, contact).getOrNull() + + // Assert + assertThat(result!!.valid).isEmpty() + assertThat(result.invalid).isEmpty() + assertThat(result.areAllInvalid).isFalse() + verify(exactly = 0) { verifyMessagesUseCase(any(), any(), any()) } + } + + @Test + fun `GIVEN verifyMessagesUseCase returns error WHEN invoke THEN propagates the error`() { + // Arrange + val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")) + every { verifyMessagesUseCase(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left() + + // Act + val result = useCase(userWallet, contact) + + // Assert + assertThat(result.leftOrNull()).isEqualTo(VerifyMessagesError.NoSigningKey) + } + + private fun contact(vararg entries: AddressEntry): Contact = Contact( + id = ContactId("contact-1"), + walletId = UserWalletId("011"), + name = requireNotNull(ContactName("Alice").getOrNull()), + addressEntries = entries.toList(), + ) + + private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry( + id = AddressEntryId(id), + address = address, + networkId = Network.RawID("ethereum"), + memo = memo, + signature = signature, + ) + + private fun expectedPayload(contact: Contact, entry: AddressEntry): String = + entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SignHashesError.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SignHashesError.kt new file mode 100644 index 0000000000..4bd95798d9 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/SignHashesError.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.transaction.error + +sealed class SignHashesError { + + /** The wallet has no usable signing key (e.g. it is locked or has no secp256k1 key). */ + data object NoSigningKey : SignHashesError() + + /** The signing session failed or was canceled by the user. */ + data class SigningFailed(val message: String) : SignHashesError() +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/error/VerifyMessagesError.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/VerifyMessagesError.kt new file mode 100644 index 0000000000..eac98d0847 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/error/VerifyMessagesError.kt @@ -0,0 +1,7 @@ +package com.tangem.domain.transaction.error + +sealed class VerifyMessagesError { + + /** The wallet has no usable signing key (e.g. it is locked or has no secp256k1 key). */ + data object NoSigningKey : VerifyMessagesError() +} \ No newline at end of file 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 new file mode 100644 index 0000000000..6cb02f2aaa --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrimaryPublicKey.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.transaction.usecase + +import com.tangem.common.card.EllipticCurve +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. + */ +internal fun UserWallet.primarySecp256k1PublicKey(): ByteArray? = when (this) { + is UserWallet.Cold -> scanResponse.card.wallets + .firstOrNull { it.curve == EllipticCurve.Secp256k1 } + ?.publicKey + is UserWallet.Hot -> wallets + ?.firstOrNull { it.curve == EllipticCurve.Secp256k1 } + ?.publicKey +} \ No newline at end of file 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 new file mode 100644 index 0000000000..4e7ac3d8a0 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignHashesUseCase.kt @@ -0,0 +1,62 @@ +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/VerifyMessagesUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCase.kt new file mode 100644 index 0000000000..c395413494 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCase.kt @@ -0,0 +1,47 @@ +package com.tangem.domain.transaction.usecase + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.common.card.EllipticCurve +import com.tangem.crypto.CryptoUtils +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.error.VerifyMessagesError + +/** + * Verifies each of the given [messages] against [userWallet]'s primary secp256k1 key — the + * counterpart of [SignHashesUseCase]. + * + * 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. + * + * 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, + * malformed signature) or a missing signature for that index yields `false` for that element. The + * wallet's signing key being unavailable is a [VerifyMessagesError.NoSigningKey] failure (nothing can + * be verified) rather than a list of `false`s. + */ +class VerifyMessagesUseCase { + + operator fun invoke( + userWallet: UserWallet, + messages: List, + signatures: List, + ): Either> { + val publicKey = userWallet.primarySecp256k1PublicKey() + ?: return VerifyMessagesError.NoSigningKey.left() + + val results = messages.mapIndexed { index, message -> + val signature = signatures.getOrNull(index) ?: return@mapIndexed false + CryptoUtils.verify( + publicKey = publicKey, + message = message, + signature = signature, + curve = EllipticCurve.Secp256k1, + ) + } + return results.right() + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..a7f6dd8f7d --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignHashesUseCaseTest.kt @@ -0,0 +1,131 @@ +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/VerifyMessagesUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCaseTest.kt new file mode 100644 index 0000000000..103a32cc49 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/VerifyMessagesUseCaseTest.kt @@ -0,0 +1,120 @@ +package com.tangem.domain.transaction.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.card.EllipticCurve +import com.tangem.crypto.CryptoUtils +import com.tangem.crypto.Secp256k1 +import com.tangem.domain.models.MobileWallet +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.error.VerifyMessagesError +import com.tangem.utils.extensions.hexToBytes +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.security.MessageDigest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class VerifyMessagesUseCaseTest { + + private val useCase = VerifyMessagesUseCase() + + // A valid secp256k1 key pair. The card signs the raw SHA-256 digest of each message. + private val privateKey = "FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632550".hexToBytes() + private val publicKey = CryptoUtils.generatePublicKey(privateKey, EllipticCurve.Secp256k1) + + @BeforeAll + fun initCrypto() { + CryptoUtils.initCrypto() + } + + @Test + fun `GIVEN every signature matches its message WHEN invoke THEN all results are true`() { + // Arrange + val messages = listOf("first".toByteArray(), "second".toByteArray()) + val signatures = messages.map(::sign) + + // Act + val result = useCase(walletWithKey(publicKey), messages, signatures) + + // Assert + assertThat(result.getOrNull()).containsExactly(true, true).inOrder() + } + + @Test + fun `GIVEN one signature is for a different message WHEN invoke THEN only that result is false`() { + // Arrange + val messages = listOf("first".toByteArray(), "second".toByteArray()) + val signatures = listOf(sign(messages[0]), sign("tampered".toByteArray())) + + // Act + val result = useCase(walletWithKey(publicKey), messages, signatures) + + // Assert + assertThat(result.getOrNull()).containsExactly(true, false).inOrder() + } + + @Test + fun `GIVEN signature was made by another wallet WHEN invoke THEN result is false`() { + // Arrange + val message = "first".toByteArray() + val signatures = listOf(sign(message)) + val otherPublicKey = CryptoUtils.generatePublicKey( + "589AEAE0EF93D7A0D7DAA8EB67E96AB02C2D8E5C0FB3D5F8BB2A03B6B2C2DF89".hexToBytes(), + EllipticCurve.Secp256k1, + ) + + // Act + val result = useCase(walletWithKey(otherPublicKey), listOf(message), signatures) + + // Assert + assertThat(result.getOrNull()).containsExactly(false) + } + + @Test + fun `GIVEN fewer signatures than messages WHEN invoke THEN missing ones are false`() { + // Arrange + val messages = listOf("first".toByteArray(), "second".toByteArray()) + val signatures = listOf(sign(messages[0])) + + // Act + val result = useCase(walletWithKey(publicKey), messages, signatures) + + // Assert + assertThat(result.getOrNull()).containsExactly(true, false).inOrder() + } + + @Test + fun `GIVEN no messages WHEN invoke THEN returns empty list`() { + // Act + val result = useCase(walletWithKey(publicKey), messages = emptyList(), signatures = emptyList()) + + // Assert + assertThat(result.getOrNull()).isEmpty() + } + + @Test + fun `GIVEN locked wallet without signing key WHEN invoke THEN returns NoSigningKey`() { + // Arrange + val lockedWallet = mockk { every { wallets } returns null } + + // Act + val result = useCase(lockedWallet, listOf("first".toByteArray()), listOf(byteArrayOf(1))) + + // Assert + assertThat(result.leftOrNull()).isEqualTo(VerifyMessagesError.NoSigningKey) + } + + /** Signs the raw SHA-256 digest of [message], mirroring what a Tangem card produces. */ + private fun sign(message: ByteArray): ByteArray { + val hash = MessageDigest.getInstance("SHA-256").digest(message) + return Secp256k1.ecdsaSignDigest(hash, privateKey) + } + + private fun walletWithKey(key: ByteArray): UserWallet.Hot = mockk { + every { wallets } returns listOf( + MobileWallet(publicKey = key, chainCode = null, curve = EllipticCurve.Secp256k1, derivedKeys = emptyMap()), + ) + } +} \ No newline at end of file From 5ac7254d82df1f65c252c1a5affc23370d998345 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Jun 2026 10:25:20 +0100 Subject: [PATCH 2/3] Updated on 2026-08-14 --- .../domain/transaction/usecase/VerifyMessagesUseCaseTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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/VerifyMessagesUseCaseTest.kt index 103a32cc49..6bf5425e3c 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/VerifyMessagesUseCaseTest.kt @@ -22,11 +22,12 @@ internal class VerifyMessagesUseCaseTest { // A valid secp256k1 key pair. The card signs the raw SHA-256 digest of each message. private val privateKey = "FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632550".hexToBytes() - private val publicKey = CryptoUtils.generatePublicKey(privateKey, EllipticCurve.Secp256k1) + private lateinit var publicKey: ByteArray @BeforeAll fun initCrypto() { CryptoUtils.initCrypto() + publicKey = CryptoUtils.generatePublicKey(privateKey, EllipticCurve.Secp256k1) } @Test From e795c935aa9e5413a02c63e668697d8ba911e69b Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Jun 2026 16:01:17 +0100 Subject: [PATCH 3/3] 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()