Updated on 2026-08-14
This commit is contained in:
commit
4d2e8812de
17 changed files with 872 additions and 2 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -236,6 +236,12 @@ internal object TransactionDomainModule {
|
|||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVerifySecp256k1MessagesUseCase(): VerifySecp256k1MessagesUseCase {
|
||||
return VerifySecp256k1MessagesUseCase()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCreateNFTTransferTransactionUseCase(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -23,5 +23,6 @@ dependencies {
|
|||
// region Test libraries
|
||||
testImplementation(projects.test.core)
|
||||
testImplementation(projects.test.mock)
|
||||
testImplementation(projects.common.test)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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<SignHashesError, Contact> = 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<List<ByteArray>>()
|
||||
val publicKeySlot = slot<ByteArray>()
|
||||
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<List<ByteArray>>(), 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<UserWallet.Hot> { 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<List<ByteArray>>(), 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<List<ByteArray>>(), 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<ByteArray>,
|
||||
publicKey: ByteArray,
|
||||
userWallet: UserWallet,
|
||||
): Either<SignHashesError, List<ByteArray>> {
|
||||
if (hashes.isEmpty()) return emptyList<ByteArray>().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
|
||||
|
|
|
|||
|
|
@ -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<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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ByteArray>(), 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<Wallet.PublicKey>()
|
||||
|
||||
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<UserWallet.Hot>()
|
||||
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(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<List<ByteArray>>(), 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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue