Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-12 14:34:23 +01:00
parent f0278c85b9
commit fc24acdf8a
15 changed files with 841 additions and 0 deletions

View file

@ -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)
}
}

View file

@ -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(

View file

@ -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<AddressEntry>,
val invalid: List<AddressEntry>,
) {
val areAllInvalid: Boolean get() = valid.isEmpty() && invalid.isNotEmpty()
}

View file

@ -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)
}

View file

@ -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<SignHashesError, Contact> = 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)
}
}

View file

@ -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<VerifyMessagesError, AddressEntriesVerification> {
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 },
)
}
}
}

View file

@ -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<List<ByteArray>>()
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))
}
}

View file

@ -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<List<ByteArray>>()
val signaturesSlot = slot<List<ByteArray>>()
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<List<ByteArray>>()
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
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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<ByteArray>,
): Either<SignHashesError, List<ByteArray>> {
if (hashes.isEmpty()) return emptyList<ByteArray>().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),
)
}
}

View file

@ -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<ByteArray>,
signatures: List<ByteArray>,
): Either<VerifyMessagesError, List<Boolean>> {
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()
}
}

View file

@ -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<Wallet.PublicKey>()
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<UserWallet.Hot> {
every { wallets } returns listOf(mobileWallet(curve = EllipticCurve.Secp256k1, publicKey = byteArrayOf(42)))
}
val signer: TransactionSigner = mockk()
val publicKeySlot = slot<Wallet.PublicKey>()
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<UserWallet.Hot> {
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<List<ByteArray>>(), 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(),
)
}

View file

@ -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<UserWallet.Hot> { 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()),
)
}
}