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