diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt index 16871f2f13..95f8b0eb83 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -200,25 +200,24 @@ internal class DefaultWcPairUseCase @AssistedInject constructor( verifyContext: Wallet.Model.VerifyContext, ): Either = 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 { diff --git a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt index 34caed274f..ed502a82cd 100644 --- a/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt +++ b/data/wallet-connect/src/test/kotlin/com/tangem/domain/walletconnect/DefaultWcPairUseCaseTest.kt @@ -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()) } + } } \ No newline at end of file