Updated on 2026-08-14
This commit is contained in:
parent
de838113af
commit
e26c124d0f
2 changed files with 103 additions and 11 deletions
|
|
@ -200,25 +200,24 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
|
|||
verifyContext: Wallet.Model.VerifyContext,
|
||||
): Either<WcPairError, WcPairState.Proposal> = runCatching {
|
||||
val proposalAccountNetwork = associateNetworksDelegate.associateAccounts(sessionProposal)
|
||||
// Display URL: shown to the user and logged to analytics. Reown's verified origin when
|
||||
// present, otherwise its `verify.walletconnect.org` fallback. NOT trustworthy for
|
||||
// security checks: when validation is INVALID, getDappOriginUrl returns the dApp-claimed
|
||||
// origin (so the UI can show what was claimed), which a scam dApp can spoof.
|
||||
// Display URL: shown to the user and logged to analytics. getDappOriginUrl() returns the
|
||||
// Verify-attested origin (verifyContext.origin), or the verify.walletconnect.org fallback
|
||||
// when origin is empty. Display only — the security verdict is decided below (see
|
||||
// isDomainConfirmed), where sessionProposal.url is used solely for a host-equality check.
|
||||
val displayUrl = verifyContext.getDappOriginUrl()
|
||||
val verificationInfo = when {
|
||||
verifyContext.validation == Wallet.Model.Validation.INVALID -> CheckDAppResult.UNSAFE
|
||||
verifyContext.isScam == true -> CheckDAppResult.UNSAFE
|
||||
// BlockAid is scanned only against the Reown-verified origin (validation == VALID
|
||||
// guarantees Reown confirmed origin matches the dApp's registered domain).
|
||||
// For UNKNOWN we have no trustworthy URL: passing a dApp-claimed URL would let an
|
||||
// impersonator (e.g. a scam claiming metadata.url=dydx.trade) inherit its target's
|
||||
// BlockAid verdict.
|
||||
verifyContext.validation == Wallet.Model.Validation.VALID -> {
|
||||
// BlockAid scans only the Verify-attested origin (verifyContext.origin), reached for
|
||||
// VALID or for a false-positive INVALID whose metadata host matches that origin (see
|
||||
// isDomainConfirmed). For UNKNOWN there is no trustworthy origin, so BlockAid is
|
||||
// skipped to avoid letting an impersonator inherit its target's verdict.
|
||||
isDomainConfirmed(verifyContext, sessionProposal.url) -> {
|
||||
blockAidVerifier.verifyDApp(DAppData(verifyContext.origin)).getOrElse { error ->
|
||||
TangemLogger.withTag(WC_TAG).e("Failed to verify DApp ${sessionProposal.name}", error)
|
||||
CheckDAppResult.FAILED_TO_VERIFY
|
||||
}
|
||||
}
|
||||
verifyContext.validation == Wallet.Model.Validation.INVALID -> CheckDAppResult.UNSAFE
|
||||
else -> CheckDAppResult.FAILED_TO_VERIFY
|
||||
}
|
||||
val requestedNetworks = proposalAccountNetwork
|
||||
|
|
@ -254,8 +253,31 @@ internal class DefaultWcPairUseCase @AssistedInject constructor(
|
|||
},
|
||||
)
|
||||
|
||||
private fun isDomainConfirmed(verifyContext: Wallet.Model.VerifyContext, metadataUrl: String): Boolean {
|
||||
return when (verifyContext.validation) {
|
||||
Wallet.Model.Validation.VALID -> true
|
||||
Wallet.Model.Validation.INVALID -> hostsMatchWithScheme(metadataUrl, verifyContext.origin)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun hostsMatchWithScheme(metadataUrl: String, origin: String): Boolean = runCatching {
|
||||
val metadataHost = URI(metadataUrl.ensureScheme()).host?.lowercase()
|
||||
val originHost = URI(origin.ensureScheme()).host?.lowercase()
|
||||
!metadataHost.isNullOrEmpty() && metadataHost == originHost
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun String.ensureScheme(): String =
|
||||
if (startsWith(HTTP_SCHEME, ignoreCase = true) || startsWith(HTTPS_SCHEME, ignoreCase = true)) {
|
||||
this
|
||||
} else {
|
||||
HTTPS_SCHEME + this
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PENDING_SESSION_EXPIRED_DURATION_MIN = 15L
|
||||
const val HTTP_SCHEME = "http://"
|
||||
const val HTTPS_SCHEME = "https://"
|
||||
}
|
||||
|
||||
private sealed interface TerminalAction {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ import com.tangem.domain.walletconnect.model.WcSession
|
|||
import com.tangem.domain.walletconnect.model.WcSessionApprove
|
||||
import com.tangem.domain.walletconnect.usecase.pair.WcPairState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.mockk
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
|
@ -287,4 +289,72 @@ internal class DefaultWcPairUseCaseTest {
|
|||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pair treats invalid validation with scheme-less metadata url matching origin as verified`() = runTest {
|
||||
val schemelessProposal = sdkProposal.copy(url = "app.eigenlayer.xyz")
|
||||
val invalidVerifyContext = sdkVerifyContext.copy(
|
||||
origin = "https://app.eigenlayer.xyz",
|
||||
validation = Wallet.Model.Validation.INVALID,
|
||||
)
|
||||
coEvery { sdkDelegate.pair(url) } returns (schemelessProposal to invalidVerifyContext).right()
|
||||
coEvery { associateNetworksDelegate.associateAccounts(schemelessProposal) } returns mapOf()
|
||||
coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE }
|
||||
|
||||
val useCase = useCaseFactory()
|
||||
useCase.invoke().test {
|
||||
assertEquals(loading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.pair(url)
|
||||
blockAidVerifier.verifyDApp(DAppData(invalidVerifyContext.origin))
|
||||
}
|
||||
val proposal = assertInstanceOf(WcPairState.Proposal::class.java, awaitItem())
|
||||
assertEquals(CheckDAppResult.SAFE, proposal.dAppSession.securityStatus)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pair treats invalid validation with case-differing scheme-less metadata url as verified`() = runTest {
|
||||
val schemelessProposal = sdkProposal.copy(url = "APP.EigenLayer.xyz")
|
||||
val invalidVerifyContext = sdkVerifyContext.copy(
|
||||
origin = "HTTPS://app.eigenlayer.xyz",
|
||||
validation = Wallet.Model.Validation.INVALID,
|
||||
)
|
||||
coEvery { sdkDelegate.pair(url) } returns (schemelessProposal to invalidVerifyContext).right()
|
||||
coEvery { associateNetworksDelegate.associateAccounts(schemelessProposal) } returns mapOf()
|
||||
coEvery { blockAidVerifier.verifyDApp(any()) } returns Either.catch { CheckDAppResult.SAFE }
|
||||
|
||||
val useCase = useCaseFactory()
|
||||
useCase.invoke().test {
|
||||
assertEquals(loading, awaitItem())
|
||||
coVerifyOrder {
|
||||
sdkDelegate.pair(url)
|
||||
blockAidVerifier.verifyDApp(DAppData(invalidVerifyContext.origin))
|
||||
}
|
||||
val proposal = assertInstanceOf(WcPairState.Proposal::class.java, awaitItem())
|
||||
assertEquals(CheckDAppResult.SAFE, proposal.dAppSession.securityStatus)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pair keeps genuine invalid domain unsafe and skips blockaid`() = runTest {
|
||||
val proposal = sdkProposal.copy(url = "https://legit-dapp.example/")
|
||||
val invalidVerifyContext = sdkVerifyContext.copy(
|
||||
origin = "https://phishing.example/",
|
||||
validation = Wallet.Model.Validation.INVALID,
|
||||
)
|
||||
coEvery { sdkDelegate.pair(url) } returns (proposal to invalidVerifyContext).right()
|
||||
coEvery { associateNetworksDelegate.associateAccounts(proposal) } returns mapOf()
|
||||
|
||||
val useCase = useCaseFactory()
|
||||
useCase.invoke().test {
|
||||
assertEquals(loading, awaitItem())
|
||||
val state = assertInstanceOf(WcPairState.Proposal::class.java, awaitItem())
|
||||
assertEquals(CheckDAppResult.UNSAFE, state.dAppSession.securityStatus)
|
||||
expectNoEvents()
|
||||
}
|
||||
coVerify(exactly = 0) { blockAidVerifier.verifyDApp(any()) }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue