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..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 @@ -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.VerifySecp256k1MessagesUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,4 +26,12 @@ object AddressBookDomainModule { getNetworkAddressesUseCase = getNetworkAddressesUseCase, ) } + + @Provides + @Singleton + 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 1172d04238..667b78653d 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 @@ -236,6 +236,12 @@ internal object TransactionDomainModule { ) } + @Provides + @Singleton + fun provideVerifySecp256k1MessagesUseCase(): VerifySecp256k1MessagesUseCase { + return VerifySecp256k1MessagesUseCase() + } + @Provides @Singleton fun provideCreateNFTTransferTransactionUseCase( 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/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..525745b768 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt @@ -0,0 +1,41 @@ +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.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 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 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 = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).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..d82ecd9ee4 --- /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.VerifySecp256k1MessagesUseCase +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: VerifySecp256k1MessagesUseCase, +) { + + 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..9a4405a9f4 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt @@ -0,0 +1,143 @@ +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 +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.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 +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 signUseCase: SignUseCase = mockk() + private val useCase = SignAddressEntriesUseCase(signUseCase = signUseCase) + + // 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(signUseCase) + } + + @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>() + val publicKeySlot = slot() + coEvery { + signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet)) + } 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) + // 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( + 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) { signUseCase(any>(), any(), any()) } + } + + @Test + 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 { signUseCase(any>(), any(), any()) } returns + SignHashesError.SigningFailed(message = "canceled").left() + + // Act + val result = useCase(userWallet, contact) + + // Assert + assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "canceled")) + } + + 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..88501ea467 --- /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.VerifySecp256k1MessagesUseCase +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: VerifySecp256k1MessagesUseCase = 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..f2e2e7b6db --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/PrimaryPublicKey.kt @@ -0,0 +1,21 @@ +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 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. + */ +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/SignUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignUseCase.kt index 200e8c4faa..677c40b3c5 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/VerifySecp256k1MessagesUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifySecp256k1MessagesUseCase.kt new file mode 100644 index 0000000000..d2b8b190cd --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/VerifySecp256k1MessagesUseCase.kt @@ -0,0 +1,50 @@ +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 [SignUseCase] for raw-hash signatures. + * + * 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, + * 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 VerifySecp256k1MessagesUseCase { + + 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/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/VerifySecp256k1MessagesUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/VerifySecp256k1MessagesUseCaseTest.kt new file mode 100644 index 0000000000..08837fe5df --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/VerifySecp256k1MessagesUseCaseTest.kt @@ -0,0 +1,121 @@ +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 VerifySecp256k1MessagesUseCaseTest { + + 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() + private lateinit var publicKey: ByteArray + + @BeforeAll + fun initCrypto() { + CryptoUtils.initCrypto() + publicKey = CryptoUtils.generatePublicKey(privateKey, EllipticCurve.Secp256k1) + } + + @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