diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b252af1..a9b309d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,8 +29,8 @@ android { applicationId = "com.notcvnt.rknhardering" minSdk = 26 targetSdk = 36 - versionCode = 20700 - versionName = "2.7.0" + versionCode = 20701 + versionName = "2.7.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" androidResources.localeFilters += listOf("en", "ru", "fa", "zh-rCN") diff --git a/app/src/main/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatter.kt b/app/src/main/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatter.kt index bccf952..9be69e4 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatter.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/CheckResultJsonExportFormatter.kt @@ -14,6 +14,7 @@ import com.notcvnt.rknhardering.model.IpCheckerGroupResult import com.notcvnt.rknhardering.model.IpCheckerResponse import com.notcvnt.rknhardering.model.IpComparisonResult import com.notcvnt.rknhardering.model.IpConsensusResult +import com.notcvnt.rknhardering.model.LocationSignalsFacts import com.notcvnt.rknhardering.model.LocalProxyCheckResult import com.notcvnt.rknhardering.model.LocalProxyOwner import com.notcvnt.rknhardering.model.MatchedVpnApp @@ -74,6 +75,35 @@ internal object CheckResultJsonExportFormatter { }, ) put("reasons", jsonArray(narrative.reasonRows)) + narrative.homeRoutedRoamingNote?.let { put("homeRoutedRoamingNote", it) } + val locationFacts = snapshot.result.locationSignals.locationFacts + if (locationFacts != null) { + put("homeRoutedRoaming", locationFacts.homeRoutedRoaming) + put( + "roamingDiagnostics", + JSONObject().apply { + put("networkMcc", locationFacts.networkMcc) + put("networkMnc", locationFacts.networkMnc) + put("networkCountryIso", locationFacts.networkCountryIso) + put("networkIsRussia", locationFacts.networkIsRussia) + put("homeSimMcc", locationFacts.homeSimMcc) + put("homeSimMnc", locationFacts.homeSimMnc) + put("homeSimCountryIso", locationFacts.homeSimCountryIso) + put("homeSimCountryIsRussia", locationFacts.homeSimCountryIsRussia) + put("homeSimOperatorName", locationFacts.homeSimOperatorName?.let { + maskExportValue(it, snapshot.privacyMode) + }) + put("anySimReportedRoaming", locationFacts.anySimReportedRoaming) + put("homeRoutedRoamingReason", locationFacts.homeRoutedRoamingReason) + val expectedExit = snapshot.result.geoIp.geoFacts?.expectedRoamingExit == true + put("expectedRoamingExit", expectedExit) + put( + "expectedRoamingExitReason", + snapshot.result.geoIp.geoFacts?.expectedRoamingExitReason, + ) + }, + ) + } }, ) root.put( @@ -111,6 +141,7 @@ internal object CheckResultJsonExportFormatter { put("callTransportLeaks", JSONArray().apply { category.callTransportLeaks.forEach { put(callTransportLeakToJson(it, privacyMode)) } }) put("stunProbeGroups", JSONArray().apply { category.stunProbeGroups.forEach { put(stunProbeGroupToJson(it, privacyMode)) } }) category.geoFacts?.let { put("geoFacts", geoFactsToJson(it, privacyMode)) } + category.locationFacts?.let { put("locationFacts", locationFactsToJson(it, privacyMode)) } } } @@ -119,10 +150,33 @@ internal object CheckResultJsonExportFormatter { put("ip", maskExportIp(facts.ip, privacyMode)) put("countryCode", facts.countryCode) put("asn", facts.asn?.let { maskExportValue(it, privacyMode) }) + put("asnCode", facts.asnCode) + put("isp", facts.isp?.let { maskExportValue(it, privacyMode) }) + put("org", facts.org?.let { maskExportValue(it, privacyMode) }) put("outsideRu", facts.outsideRu) put("hosting", facts.hosting) put("proxyDb", facts.proxyDb) put("fetchError", facts.fetchError) + put("expectedRoamingExit", facts.expectedRoamingExit) + put("expectedRoamingExitReason", facts.expectedRoamingExitReason) + } + } + + private fun locationFactsToJson(facts: LocationSignalsFacts, privacyMode: Boolean): JSONObject { + return JSONObject().apply { + put("networkMcc", facts.networkMcc) + put("networkMnc", facts.networkMnc) + put("networkCountryIso", facts.networkCountryIso) + put("networkOperatorName", facts.networkOperatorName?.let { maskExportValue(it, privacyMode) }) + put("networkIsRussia", facts.networkIsRussia) + put("homeSimMcc", facts.homeSimMcc) + put("homeSimMnc", facts.homeSimMnc) + put("homeSimCountryIso", facts.homeSimCountryIso) + put("homeSimCountryIsRussia", facts.homeSimCountryIsRussia) + put("homeSimOperatorName", facts.homeSimOperatorName?.let { maskExportValue(it, privacyMode) }) + put("anySimReportedRoaming", facts.anySimReportedRoaming) + put("homeRoutedRoaming", facts.homeRoutedRoaming) + put("homeRoutedRoamingReason", facts.homeRoutedRoamingReason) } } diff --git a/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt b/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt index 4b9b4a9..f75bd5c 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/MainActivity.kt @@ -273,6 +273,7 @@ class MainActivity : AppCompatActivity() { private lateinit var iconVerdict: ImageView private lateinit var textVerdict: TextView private lateinit var textVerdictExplanation: TextView + private lateinit var textVerdictHomeRoutedRoamingNote: TextView private lateinit var btnVerdictDetails: MaterialButton private lateinit var verdictDetailsDivider: View private lateinit var verdictDetailsContent: LinearLayout @@ -522,6 +523,7 @@ class MainActivity : AppCompatActivity() { iconVerdict = findViewById(R.id.iconVerdict) textVerdict = findViewById(R.id.textVerdict) textVerdictExplanation = findViewById(R.id.textVerdictExplanation) + textVerdictHomeRoutedRoamingNote = findViewById(R.id.textVerdictHomeRoutedRoamingNote) btnVerdictDetails = findViewById(R.id.btnVerdictDetails) verdictDetailsDivider = findViewById(R.id.verdictDetailsDivider) verdictDetailsContent = findViewById(R.id.verdictDetailsContent) @@ -3019,6 +3021,19 @@ class MainActivity : AppCompatActivity() { textVerdictExplanation.text = narrative.explanation textVerdictExplanation.visibility = View.VISIBLE + val note = narrative.homeRoutedRoamingNote + if (note != null) { + textVerdictHomeRoutedRoamingNote.text = note + textVerdictHomeRoutedRoamingNote.visibility = View.VISIBLE + textVerdictHomeRoutedRoamingNote.setTextColor(onSurfaceColor()) + textVerdictHomeRoutedRoamingNote.setBackgroundResource( + R.drawable.bg_verdict_home_routed_roaming_note, + ) + } else { + textVerdictHomeRoutedRoamingNote.text = "" + textVerdictHomeRoutedRoamingNote.visibility = View.GONE + } + verdictDetailsContent.removeAllViews() addVerdictSection( title = getString(R.string.main_verdict_section_meaning), @@ -3111,6 +3126,8 @@ class MainActivity : AppCompatActivity() { textVerdict.text = "" textVerdictExplanation.text = "" textVerdictExplanation.visibility = View.GONE + textVerdictHomeRoutedRoamingNote.text = "" + textVerdictHomeRoutedRoamingNote.visibility = View.GONE verdictDetailsDivider.visibility = View.GONE btnVerdictDetails.visibility = View.GONE btnVerdictDetails.text = getString(R.string.main_verdict_details) diff --git a/app/src/main/java/com/notcvnt/rknhardering/VerdictNarrative.kt b/app/src/main/java/com/notcvnt/rknhardering/VerdictNarrative.kt index 575f753..1945589 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/VerdictNarrative.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/VerdictNarrative.kt @@ -40,6 +40,7 @@ data class VerdictNarrative( val meaningRows: List, val discoveredRows: List, val reasonRows: List, + val homeRoutedRoamingNote: String? = null, ) object VerdictNarrativeBuilder { @@ -57,9 +58,30 @@ object VerdictNarrativeBuilder { meaningRows = buildMeaningRows(context, result.verdict, exposureStatus), discoveredRows = buildDiscoveredRows(context, snapshot, exposureStatus, privacyMode), reasonRows = buildReasonRows(context, result), + homeRoutedRoamingNote = buildHomeRoutedRoamingNote(context, result), ) } + private fun buildHomeRoutedRoamingNote(context: Context, result: CheckResult): String? { + val facts = result.locationSignals.locationFacts ?: return null + if (!facts.homeRoutedRoaming) return null + val operator = facts.homeSimOperatorName?.takeIf { it.isNotBlank() } + val country = facts.homeSimCountryIso?.takeIf { it.isNotBlank() } + val asnConfirmed = result.geoIp.geoFacts?.expectedRoamingExit == true + return when { + asnConfirmed && operator != null && country != null -> + context.getString(R.string.narrative_home_routed_roaming_confirmed, operator, country) + asnConfirmed && country != null -> + context.getString(R.string.narrative_home_routed_roaming_confirmed_country, country) + operator != null && country != null -> + context.getString(R.string.narrative_home_routed_roaming_likely, operator, country) + country != null -> + context.getString(R.string.narrative_home_routed_roaming_likely_country, country) + else -> + context.getString(R.string.narrative_home_routed_roaming_generic) + } + } + private fun collectSnapshot(context: Context, result: CheckResult): Snapshot { val xrayApi = result.bypassResult.xrayApiScanResult val gatewayLeakFinding = result.bypassResult.findings.firstOrNull { diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/BypassChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/BypassChecker.kt index 8b048b5..a6801a3 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/BypassChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/BypassChecker.kt @@ -473,31 +473,40 @@ object BypassChecker { val proxyDetected = proxyCheck.status == LocalProxyCheckStatus.CONFIRMED_BYPASS + // Treat AUTH_REQUIRED endpoints with no resolved owner as informational + // noise: bypass cannot be confirmed (no probe possible) and we can't + // attribute the listening socket to a known app. Emitting them as + // evidence inflates the verdict and confuses the user. + val authRequiredUnresolved = proxyCheck.status == LocalProxyCheckStatus.AUTH_REQUIRED && + proxyCheck.ownerStatus == LocalProxyOwnerStatus.UNRESOLVED findings.add( Finding( description = description, detected = proxyDetected, - needsReview = !proxyDetected, + needsReview = !proxyDetected && !authRequiredUnresolved, + isInformational = authRequiredUnresolved, source = EvidenceSource.LOCAL_PROXY, confidence = EvidenceConfidence.MEDIUM, family = familySuffix, packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner), ), ) - evidence.add( - EvidenceItem( - source = EvidenceSource.LOCAL_PROXY, - detected = true, - confidence = EvidenceConfidence.MEDIUM, - description = buildString { - append("Detected open ${proxyEndpoint.type.name} proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)}") - append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus)) - append(ownerMetadataSuffix) - }, - family = familySuffix, - packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner), - ), - ) + if (!authRequiredUnresolved) { + evidence.add( + EvidenceItem( + source = EvidenceSource.LOCAL_PROXY, + detected = true, + confidence = EvidenceConfidence.MEDIUM, + description = buildString { + append("Detected open ${proxyEndpoint.type.name} proxy at ${formatHostPort(proxyEndpoint.host, proxyEndpoint.port)}") + append(formatOwnerSuffix(context, proxyCheck.owner, proxyCheck.ownerStatus)) + append(ownerMetadataSuffix) + }, + family = familySuffix, + packageName = LocalProxyOwnerFormatter.packageName(proxyCheck.owner), + ), + ) + } if (proxyCheck.status != LocalProxyCheckStatus.AUTH_REQUIRED) { findings.add(Finding(context.getString(R.string.checker_bypass_proxy_ip, proxyCheck.proxyIp ?: unavailable))) diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt index 03fbdd9..1ce19d5 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/CdnPullingChecker.kt @@ -207,16 +207,23 @@ object CdnPullingChecker { }.distinct() val allSuccessfulResponsesExposeIp = successfulResponses.isNotEmpty() && successfulResponses.all { it.ip != null } - val allActionableResponsesExposeIp = actionableResponses.isNotEmpty() && actionableResponses.all { it.ip != null } val hasError = successfulCount == 0 val detected = actionableCount > 0 val ipv4Conflict = actionableIpv4s.size > 1 val ipv6Conflict = actionableIpv6s.size > 1 + // Only raise review if we actually saw conflicting IPs across actionable + // targets. A single actionable target without an exposed IP is not a + // signal — it just means the endpoint did not advertise one. Likewise, + // partial successes (some endpoints failed entirely) are reported via + // the per-finding error path, not by flagging the whole card. + val ipv4MismatchAcrossAll = allIpv4s.size > 1 + val ipv6MismatchAcrossAll = allIpv6s.size > 1 val needsReview = detected && ( ipv4Conflict || ipv6Conflict || - !allActionableResponsesExposeIp + ipv4MismatchAcrossAll || + ipv6MismatchAcrossAll ) val findings = buildFindings(successfulResponses, responses) diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt index 706be2b..6a58623 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/GeoIpChecker.kt @@ -539,10 +539,14 @@ object GeoIpChecker { val countryCode = snapshot.countryCode.uppercase().ifBlank { null } val outsideRu = countryCode != null && countryCode != "RU" + val asn = snapshot.asn.takeUnless { it.isBlank() || it == "N/A" } val geoFacts = GeoIpFacts( ip = snapshot.ip.takeUnless { it.isBlank() || it == "N/A" }, countryCode = countryCode, - asn = snapshot.asn.takeUnless { it.isBlank() || it == "N/A" }, + asn = asn, + asnCode = HomeNetworkCatalog.extractAsnCode(asn), + isp = snapshot.isp.takeUnless { it.isBlank() || it == "N/A" }, + org = snapshot.org.takeUnless { it.isBlank() || it == "N/A" }, outsideRu = outsideRu, hosting = snapshot.isHosting, proxyDb = snapshot.isProxy, diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/HomeNetworkCatalog.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/HomeNetworkCatalog.kt new file mode 100644 index 0000000..e3430e2 --- /dev/null +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/HomeNetworkCatalog.kt @@ -0,0 +1,281 @@ +package com.notcvnt.rknhardering.checker + +/** + * Static catalog of home networks for SIM operators we want to recognize as + * "expected roaming exit" — the operator's home APN/PGW always terminates the + * data session, so when the user is roaming the public IP belongs to the home + * network instead of the visited country. Without this knowledge such legit + * roaming is indistinguishable from a real bypass. + * + * Keys are `MCC` ("250") or `MCC-MNC` ("208-15"). MCC-only entries match any + * MNC of that country (used as a fallback when MNC is unknown). + * + * The entry stores ASN codes (e.g. 51207) and substring keywords that we look + * for in the GeoIP `isp` / `org` / `asn` strings. ASN match is the strong + * signal; keywords are a fallback when the ASN field isn't structured. + */ +internal data class HomeNetworkProfile( + val mcc: String, + val mnc: String?, + val country: String, + val operator: String, + val asnCodes: Set, + val keywords: Set, +) + +internal object HomeNetworkCatalog { + + private val PROFILES: List = listOf( + // France — Free Mobile (issue #63) + HomeNetworkProfile( + mcc = "208", + mnc = "15", + country = "FR", + operator = "Free Mobile", + asnCodes = setOf("51207"), + keywords = setOf("free mobile", "free sas"), + ), + HomeNetworkProfile( + mcc = "208", + mnc = "16", + country = "FR", + operator = "Free Mobile", + asnCodes = setOf("51207"), + keywords = setOf("free mobile", "free sas"), + ), + // France — Orange + HomeNetworkProfile( + mcc = "208", + mnc = "01", + country = "FR", + operator = "Orange", + asnCodes = setOf("3215"), + keywords = setOf("orange s.a.", "france telecom", "orange france"), + ), + HomeNetworkProfile( + mcc = "208", + mnc = "02", + country = "FR", + operator = "Orange", + asnCodes = setOf("3215"), + keywords = setOf("orange s.a.", "france telecom", "orange france"), + ), + // France — SFR + HomeNetworkProfile( + mcc = "208", + mnc = "10", + country = "FR", + operator = "SFR", + asnCodes = setOf("15557"), + keywords = setOf("sfr", "societe francaise du radiotelephone"), + ), + // France — Bouygues Telecom + HomeNetworkProfile( + mcc = "208", + mnc = "20", + country = "FR", + operator = "Bouygues Telecom", + asnCodes = setOf("5410"), + keywords = setOf("bouygues"), + ), + // Germany — Deutsche Telekom + HomeNetworkProfile( + mcc = "262", + mnc = "01", + country = "DE", + operator = "Telekom Deutschland", + asnCodes = setOf("3320"), + keywords = setOf("deutsche telekom", "telekom deutschland"), + ), + // Germany — Vodafone + HomeNetworkProfile( + mcc = "262", + mnc = "02", + country = "DE", + operator = "Vodafone", + asnCodes = setOf("3209", "1273"), + keywords = setOf("vodafone gmbh", "vodafone germany"), + ), + // Germany — Telefonica O2 + HomeNetworkProfile( + mcc = "262", + mnc = "07", + country = "DE", + operator = "Telefonica O2", + asnCodes = setOf("6805", "13184"), + keywords = setOf("telefonica germany", "o2 deutschland"), + ), + // United Kingdom — EE / BT + HomeNetworkProfile( + mcc = "234", + mnc = "30", + country = "GB", + operator = "EE", + asnCodes = setOf("12576", "5378"), + keywords = setOf("ee limited", "everything everywhere"), + ), + // United Kingdom — Vodafone + HomeNetworkProfile( + mcc = "234", + mnc = "15", + country = "GB", + operator = "Vodafone UK", + asnCodes = setOf("1273"), + keywords = setOf("vodafone limited", "vodafone uk"), + ), + // United Kingdom — O2 + HomeNetworkProfile( + mcc = "234", + mnc = "10", + country = "GB", + operator = "O2 UK", + asnCodes = setOf("5607"), + keywords = setOf("telefonica uk", "o2 uk"), + ), + // Italy — TIM + HomeNetworkProfile( + mcc = "222", + mnc = "01", + country = "IT", + operator = "TIM", + asnCodes = setOf("3269"), + keywords = setOf("telecom italia", "tim s.p.a."), + ), + // Italy — Vodafone + HomeNetworkProfile( + mcc = "222", + mnc = "10", + country = "IT", + operator = "Vodafone Italia", + asnCodes = setOf("30722"), + keywords = setOf("vodafone italia"), + ), + // Spain — Movistar + HomeNetworkProfile( + mcc = "214", + mnc = "07", + country = "ES", + operator = "Movistar", + asnCodes = setOf("3352"), + keywords = setOf("telefonica de espana", "movistar"), + ), + // Netherlands — KPN + HomeNetworkProfile( + mcc = "204", + mnc = "08", + country = "NL", + operator = "KPN", + asnCodes = setOf("1136"), + keywords = setOf("kpn b.v.", "kpn mobile"), + ), + // Poland — Orange / Play / Plus / T-Mobile + HomeNetworkProfile( + mcc = "260", + mnc = "03", + country = "PL", + operator = "Orange Polska", + asnCodes = setOf("5617"), + keywords = setOf("orange polska"), + ), + // United States — T-Mobile + HomeNetworkProfile( + mcc = "310", + mnc = "260", + country = "US", + operator = "T-Mobile USA", + asnCodes = setOf("21928"), + keywords = setOf("t-mobile usa"), + ), + // United States — AT&T + HomeNetworkProfile( + mcc = "310", + mnc = "410", + country = "US", + operator = "AT&T Mobility", + asnCodes = setOf("20057", "7018"), + keywords = setOf("at&t mobility", "cellco partnership", "at&t services"), + ), + // United States — Verizon + HomeNetworkProfile( + mcc = "311", + mnc = "480", + country = "US", + operator = "Verizon Wireless", + asnCodes = setOf("22394", "6167"), + keywords = setOf("verizon wireless", "cellco partnership"), + ), + // Belarus — A1 / MTS / Life + HomeNetworkProfile( + mcc = "257", + mnc = null, + country = "BY", + operator = "Belarus mobile carrier", + asnCodes = setOf("6697", "25106", "44087"), + keywords = setOf("a1 belarus", "mobile telesystems llc", "best ltd"), + ), + // Kazakhstan — Beeline / Kcell / Tele2 + HomeNetworkProfile( + mcc = "401", + mnc = null, + country = "KZ", + operator = "Kazakhstan mobile carrier", + asnCodes = setOf("21299", "29355", "35168"), + keywords = setOf("kar-tel", "kcell", "mobile telecom-service"), + ), + // Ukraine — Kyivstar / Vodafone UA / lifecell + HomeNetworkProfile( + mcc = "255", + mnc = null, + country = "UA", + operator = "Ukraine mobile carrier", + asnCodes = setOf("15895", "21497", "34058"), + keywords = setOf("kyivstar", "pjsc vf ukraine", "lifecell"), + ), + // Turkey — Turkcell / Vodafone TR + HomeNetworkProfile( + mcc = "286", + mnc = "01", + country = "TR", + operator = "Turkcell", + asnCodes = setOf("16135"), + keywords = setOf("turkcell"), + ), + ) + + fun lookup(mcc: String?, mnc: String?): HomeNetworkProfile? { + if (mcc.isNullOrBlank()) return null + if (!mnc.isNullOrBlank()) { + val exact = PROFILES.firstOrNull { it.mcc == mcc && it.mnc == mnc } + if (exact != null) return exact + } + return PROFILES.firstOrNull { it.mcc == mcc && it.mnc == null } + } + + /** + * Try to match `geoFacts` ASN/ISP strings against the SIM home network. + * Returns reason string (for debugging/UI) on match, null otherwise. + */ + fun matchExpectedExit(profile: HomeNetworkProfile, asn: String?, isp: String?, org: String?): String? { + val asnCode = extractAsnCode(asn) + if (asnCode != null && asnCode in profile.asnCodes) { + return "ASN AS$asnCode matches ${profile.operator} home network (${profile.country})" + } + val haystack = listOf(asn, isp, org) + .filterNotNull() + .joinToString(" | ") { it.lowercase() } + if (haystack.isBlank()) return null + val keyword = profile.keywords.firstOrNull { it in haystack } + return if (keyword != null) { + "ISP/ORG matches ${profile.operator} home network (${profile.country})" + } else { + null + } + } + + private val ASN_REGEX = Regex("""AS\s*(\d+)""", RegexOption.IGNORE_CASE) + + fun extractAsnCode(asn: String?): String? { + if (asn.isNullOrBlank()) return null + return ASN_REGEX.find(asn)?.groupValues?.getOrNull(1) + } +} diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsChecker.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsChecker.kt index 2a495e6..f9a95fb 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsChecker.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/LocationSignalsChecker.kt @@ -28,6 +28,7 @@ import com.notcvnt.rknhardering.model.EvidenceConfidence import com.notcvnt.rknhardering.model.EvidenceItem import com.notcvnt.rknhardering.model.EvidenceSource import com.notcvnt.rknhardering.model.Finding +import com.notcvnt.rknhardering.model.LocationSignalsFacts import com.notcvnt.rknhardering.network.DnsResolverConfig import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.Dispatchers @@ -47,6 +48,7 @@ object LocationSignalsChecker { val simCountryIso: String?, val operatorName: String?, val isRoaming: Boolean?, + val simMnc: String? = null, ) private data class CellCollectionResult( @@ -74,6 +76,7 @@ object LocationSignalsChecker { val networkCountryIso: String?, val networkOperatorName: String?, val simCards: List, + val networkMnc: String? = null, val cellCountryCode: String?, val cellLookupSummary: String?, val cellCandidatesCount: Int, @@ -133,6 +136,7 @@ object LocationSignalsChecker { val wifiFeatureAvailable = context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI) var networkMcc: String? = null + var networkMnc: String? = null var networkCountryIso: String? = null var networkOperatorName: String? = null var cellCountryCode: String? = null @@ -145,6 +149,8 @@ object LocationSignalsChecker { val networkOperator = tm.networkOperator if (!networkOperator.isNullOrEmpty() && networkOperator.length >= 3) { networkMcc = networkOperator.substring(0, 3) + networkMnc = networkOperator.substring(3) + .takeIf { it.isNotEmpty() } } networkCountryIso = tm.networkCountryIso?.takeIf { it.isNotEmpty() } networkOperatorName = tm.networkOperatorName?.takeIf { it.isNotEmpty() } @@ -192,6 +198,7 @@ object LocationSignalsChecker { return LocationSnapshot( networkMcc = networkMcc, + networkMnc = networkMnc, networkCountryIso = networkCountryIso, networkOperatorName = networkOperatorName, simCards = simCards, @@ -260,12 +267,20 @@ object LocationSignalsChecker { val simMcc = if (!simOperator.isNullOrEmpty() && simOperator.length >= 3) { simOperator.substring(0, 3) } else null + val simMnc = if (!simOperator.isNullOrEmpty() && simOperator.length > 3) { + simOperator.substring(3) + } else null + val simCountryIso = subTm.simCountryIso?.takeIf { it.isNotEmpty() } + ?: info.countryIso?.takeIf { it.isNotEmpty() } SimCardInfo( slotIndex = info.simSlotIndex, subscriptionId = info.subscriptionId, simMcc = simMcc, - simCountryIso = subTm.simCountryIso?.takeIf { it.isNotEmpty() }, - operatorName = subTm.networkOperatorName?.takeIf { it.isNotEmpty() }, + simMnc = simMnc, + simCountryIso = simCountryIso, + operatorName = info.carrierName?.toString()?.takeIf { it.isNotEmpty() } + ?: subTm.simOperatorName?.takeIf { it.isNotEmpty() } + ?: subTm.networkOperatorName?.takeIf { it.isNotEmpty() }, isRoaming = subTm.isNetworkRoaming, ) }.getOrNull() @@ -278,13 +293,18 @@ object LocationSignalsChecker { val simMcc = if (!simOperator.isNullOrEmpty() && simOperator.length >= 3) { simOperator.substring(0, 3) } else null + val simMnc = if (!simOperator.isNullOrEmpty() && simOperator.length > 3) { + simOperator.substring(3) + } else null listOf( SimCardInfo( slotIndex = 0, subscriptionId = -1, simMcc = simMcc, + simMnc = simMnc, simCountryIso = tm.simCountryIso?.takeIf { it.isNotEmpty() }, - operatorName = tm.networkOperatorName?.takeIf { it.isNotEmpty() }, + operatorName = tm.simOperatorName?.takeIf { it.isNotEmpty() } + ?: tm.networkOperatorName?.takeIf { it.isNotEmpty() }, isRoaming = tm.isNetworkRoaming, ) ) @@ -872,11 +892,38 @@ object LocationSignalsChecker { val evidence = mutableListOf() var needsReview = false + // Pick "home" SIM for the home-routed-roaming heuristic. Prefer the + // first SIM with a known MCC; fall back to the first card we have. + val homeSim = snapshot.simCards.firstOrNull { !it.simMcc.isNullOrBlank() } + ?: snapshot.simCards.firstOrNull() + val homeSimCountryIsRussia = homeSim?.simMcc == RUSSIA_MCC + val networkIsRussia = snapshot.networkMcc == RUSSIA_MCC + val homeRoutedRoaming = networkIsRussia && + homeSim?.simMcc != null && + !homeSimCountryIsRussia + val homeRoutedRoamingReason = if (homeRoutedRoaming && homeSim != null) { + val simCountry = homeSim.simCountryIso?.uppercase(Locale.US) + ?: countryFromMcc(homeSim.simMcc) + ?: "?" + "Home SIM MCC ${homeSim.simMcc} ($simCountry) on visited Russian network MCC ${snapshot.networkMcc}" + } else { + null + } + val anySimReportedRoaming = snapshot.simCards.any { it.isRoaming == true } || + // Telephony sometimes returns isRoaming=false for foreign SIM in RU + // (e.g. Free Mobile in RU); fall back to MCC mismatch heuristic. + snapshot.simCards.any { + !it.simMcc.isNullOrBlank() && + !snapshot.networkMcc.isNullOrBlank() && + it.simMcc != snapshot.networkMcc + } + if (snapshot.networkMcc == null) { findings += Finding("PLMN: network MCC is unavailable") } else { - val networkCountry = snapshot.networkCountryIso?.uppercase(Locale.US) ?: "N/A" - val networkIsRussia = snapshot.networkMcc == RUSSIA_MCC + val networkCountry = snapshot.networkCountryIso?.uppercase(Locale.US) + ?: countryFromMcc(snapshot.networkMcc) + ?: "N/A" findings += Finding( description = "Network operator: ${snapshot.networkOperatorName ?: "N/A"} ($networkCountry)", @@ -891,22 +938,63 @@ object LocationSignalsChecker { } for (sim in snapshot.simCards) { - val simCountry = sim.simCountryIso?.uppercase(Locale.US) ?: "N/A" + // Prefer simCountryIso for the SIM label — historically this + // was sourced from the network, mislabelling foreign SIMs as + // RU when registered on a Russian visited network. + val simCountry = sim.simCountryIso?.uppercase(Locale.US) + ?: countryFromMcc(sim.simMcc) + ?: "N/A" val operatorPart = sim.operatorName?.let { ", $it" } ?: "" findings += Finding( description = "SIM[${sim.slotIndex}] MCC: ${sim.simMcc ?: "N/A"} ($simCountry)$operatorPart", isInformational = true, ) - when (sim.isRoaming) { - true -> findings += Finding("SIM[${sim.slotIndex}] Roaming: yes", isInformational = true) - false -> findings += Finding("SIM[${sim.slotIndex}] Roaming: no", isInformational = true) - null -> Unit + val effectiveRoaming: Boolean? = when { + sim.isRoaming == true -> true + !sim.simMcc.isNullOrBlank() && + !snapshot.networkMcc.isNullOrBlank() && + sim.simMcc != snapshot.networkMcc -> true + sim.isRoaming == false -> false + else -> null + } + val roamingLabel = when (effectiveRoaming) { + true -> "yes" + false -> "no" + null -> null + } + if (roamingLabel != null) { + findings += Finding( + description = "SIM[${sim.slotIndex}] Roaming: $roamingLabel", + isInformational = true, + ) } } + if (homeRoutedRoaming) { + val description = "home_routed_roaming:true (home SIM MCC ${homeSim?.simMcc}, " + + "visited network MCC ${snapshot.networkMcc})" + findings += Finding( + description = description, + source = EvidenceSource.HOME_ROUTED_ROAMING, + confidence = EvidenceConfidence.MEDIUM, + isInformational = false, + ) + evidence += EvidenceItem( + source = EvidenceSource.HOME_ROUTED_ROAMING, + detected = true, + confidence = EvidenceConfidence.MEDIUM, + description = homeRoutedRoamingReason + ?: "Foreign SIM connected via Russian visited network", + ) + } + if (!networkIsRussia) { + // Foreign visited network. If this is a Russian SIM roaming + // abroad (homeSimCountryIsRussia), the public IP is expected + // to belong to the home (Russian) operator and bypass cannot + // be inferred from country alone — drop confidence to LOW. val matchingSim = snapshot.simCards.firstOrNull { it.simMcc == snapshot.networkMcc } - val confidence = if (matchingSim?.isRoaming == true) { + val confidence = if (matchingSim?.isRoaming == true || homeSimCountryIsRussia) { EvidenceConfidence.LOW } else { EvidenceConfidence.MEDIUM @@ -980,15 +1068,35 @@ object LocationSignalsChecker { } val detected = evidence.any { - it.detected && it.confidence >= EvidenceConfidence.MEDIUM + it.detected && + it.confidence >= EvidenceConfidence.MEDIUM && + it.source != EvidenceSource.HOME_ROUTED_ROAMING } + val locationFacts = LocationSignalsFacts( + networkMcc = snapshot.networkMcc, + networkMnc = snapshot.networkMnc, + networkCountryIso = snapshot.networkCountryIso?.uppercase(Locale.US), + networkOperatorName = snapshot.networkOperatorName, + networkIsRussia = networkIsRussia, + homeSimMcc = homeSim?.simMcc, + homeSimMnc = homeSim?.simMnc, + homeSimCountryIso = homeSim?.simCountryIso?.uppercase(Locale.US) + ?: countryFromMcc(homeSim?.simMcc), + homeSimCountryIsRussia = homeSimCountryIsRussia, + homeSimOperatorName = homeSim?.operatorName, + anySimReportedRoaming = anySimReportedRoaming, + homeRoutedRoaming = homeRoutedRoaming, + homeRoutedRoamingReason = homeRoutedRoamingReason, + ) + val result = CategoryResult( name = "Location signals", detected = detected, findings = findings, needsReview = needsReview, evidence = evidence, + locationFacts = locationFacts, ) return LocationSignalsDiagnosticsRegistry.attach( result, @@ -1016,4 +1124,75 @@ object LocationSignalsChecker { } private val MAC_ADDRESS_REGEX = Regex("^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$") + + /** + * Lightweight MCC → ISO 3166-1 alpha-2 lookup for the most common + * countries. Used to label SIM/Network country when telephony APIs + * return blank simCountryIso (typical when SIM-MCC and Network-MCC + * disagree). Not exhaustive — falls back to null. + */ + private fun countryFromMcc(mcc: String?): String? { + if (mcc.isNullOrBlank()) return null + return MCC_TO_ISO[mcc] + } + + private val MCC_TO_ISO: Map = mapOf( + "202" to "GR", "204" to "NL", "206" to "BE", "208" to "FR", + "212" to "MC", "213" to "AD", "214" to "ES", "216" to "HU", + "218" to "BA", "219" to "HR", "220" to "RS", "222" to "IT", + "226" to "RO", "228" to "CH", "230" to "CZ", "231" to "SK", + "232" to "AT", "234" to "GB", "235" to "GB", "238" to "DK", + "240" to "SE", "242" to "NO", "244" to "FI", "246" to "LT", + "247" to "LV", "248" to "EE", "250" to "RU", "255" to "UA", + "257" to "BY", "259" to "MD", "260" to "PL", "262" to "DE", + "266" to "GI", "268" to "PT", "270" to "LU", "272" to "IE", + "274" to "IS", "276" to "AL", "278" to "MT", "280" to "CY", + "282" to "GE", "283" to "AM", "284" to "BG", "286" to "TR", + "288" to "FO", "290" to "GL", "293" to "SI", "294" to "MK", + "295" to "LI", "297" to "ME", "310" to "US", "311" to "US", + "312" to "US", "313" to "US", "314" to "US", "315" to "US", + "316" to "US", "330" to "PR", "334" to "MX", "338" to "JM", + "340" to "MQ", "342" to "BB", "346" to "KY", "348" to "VG", + "350" to "BM", "352" to "GD", "354" to "MS", "356" to "KN", + "358" to "LC", "360" to "VC", "362" to "AN", "363" to "AW", + "364" to "BS", "365" to "AI", "366" to "DM", "368" to "CU", + "370" to "DO", "372" to "HT", "374" to "TT", "376" to "TC", + "400" to "AZ", "401" to "KZ", "402" to "BT", "404" to "IN", + "405" to "IN", "410" to "PK", "412" to "AF", "413" to "LK", + "414" to "MM", "415" to "LB", "416" to "JO", "417" to "SY", + "418" to "IQ", "419" to "KW", "420" to "SA", "421" to "YE", + "422" to "OM", "424" to "AE", "425" to "IL", "426" to "BH", + "427" to "QA", "428" to "MN", "429" to "NP", "430" to "AE", + "431" to "AE", "432" to "IR", "434" to "UZ", "436" to "TJ", + "437" to "KG", "438" to "TM", "440" to "JP", "441" to "JP", + "450" to "KR", "452" to "VN", "454" to "HK", "455" to "MO", + "456" to "KH", "457" to "LA", "460" to "CN", "461" to "CN", + "466" to "TW", "467" to "KP", "470" to "BD", "472" to "MV", + "502" to "MY", "505" to "AU", "510" to "ID", "514" to "TL", + "515" to "PH", "520" to "TH", "525" to "SG", "528" to "BN", + "530" to "NZ", "534" to "MP", "535" to "GU", "536" to "NR", + "537" to "PG", "539" to "TO", "540" to "SB", "541" to "VU", + "542" to "FJ", "543" to "WF", "544" to "AS", "545" to "KI", + "546" to "NC", "547" to "PF", "548" to "CK", "549" to "WS", + "550" to "FM", "551" to "MH", "552" to "PW", "602" to "EG", + "603" to "DZ", "604" to "MA", "605" to "TN", "606" to "LY", + "607" to "GM", "608" to "SN", "609" to "MR", "610" to "ML", + "611" to "GN", "612" to "CI", "613" to "BF", "614" to "NE", + "615" to "TG", "616" to "BJ", "617" to "MU", "618" to "LR", + "619" to "SL", "620" to "GH", "621" to "NG", "622" to "TD", + "623" to "CF", "624" to "CM", "625" to "CV", "626" to "ST", + "627" to "GQ", "628" to "GA", "629" to "CG", "630" to "CD", + "631" to "AO", "632" to "GW", "633" to "SC", "634" to "SD", + "635" to "RW", "636" to "ET", "637" to "SO", "638" to "DJ", + "639" to "KE", "640" to "TZ", "641" to "UG", "642" to "BI", + "643" to "MZ", "645" to "ZM", "646" to "MG", "647" to "RE", + "648" to "ZW", "649" to "NA", "650" to "MW", "651" to "LS", + "652" to "BW", "653" to "SZ", "654" to "KM", "655" to "ZA", + "657" to "ER", "659" to "SS", "702" to "BZ", "704" to "GT", + "706" to "SV", "708" to "HN", "710" to "NI", "712" to "CR", + "714" to "PA", "716" to "PE", "722" to "AR", "724" to "BR", + "730" to "CL", "732" to "CO", "734" to "VE", "736" to "BO", + "738" to "GY", "740" to "EC", "744" to "PY", "746" to "SR", + "748" to "UY", + ) } diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt index 7277eb5..be96d4c 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/VerdictEngine.kt @@ -77,14 +77,19 @@ object VerdictEngine { it.description.contains("cell_country_ru:true") || it.description.contains("location_country_ru:true") } + val homeRoutedRoaming = locationSignals.locationFacts?.homeRoutedRoaming == true val geo = geoIp.geoFacts + // Home-routed roaming: foreign SIM on a Russian visited network + // legitimately exits via the SIM's home country. Treat the resulting + // geo mismatch as expected and never auto-detect bypass on geo alone. + val expectedRoamingExit = geo?.expectedRoamingExit == true || homeRoutedRoaming val geoAxisAvailable = geoCheckAvailable && geo?.fetchError != true val anyOtherSignal = directSigns.evidence.any { it.detected } || indirectSigns.evidence.any { it.detected } || ipConsensus.crossChannelMismatch || ipConsensus.probeTargetDivergence || ipConsensus.probeTargetDirectDivergence - if (locationConfirmsRussia && geo?.outsideRu == true) { + if (locationConfirmsRussia && geo?.outsideRu == true && !expectedRoamingExit) { return Verdict.DETECTED } if (locationConfirmsRussia && @@ -96,7 +101,7 @@ object VerdictEngine { } // R5 — 3-bit matrix (geo x direct x indirect) - val geoHit = geo?.outsideRu == true + val geoHit = geo?.outsideRu == true && !expectedRoamingExit val directHit = directSigns.evidence.any { it.detected && it.source in MATRIX_DIRECT_SOURCES } val indirectHit = indirectSigns.evidence.any { it.detected && it.source in MATRIX_INDIRECT_SOURCES } || nativeSigns.evidence.any { it.detected && it.source in MATRIX_INDIRECT_SOURCES } @@ -122,7 +127,7 @@ object VerdictEngine { val tunProbeReview = directSigns.evidence.any { it.source == EvidenceSource.TUN_ACTIVE_PROBE && !it.detected } - val locationSignalHit = locationSignals.detected + val locationSignalHit = locationSignals.detected && !expectedRoamingExit if (matrix == Verdict.NOT_DETECTED && ( bypassResult.needsReview || directSigns.needsReview || diff --git a/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt b/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt index 26d5f9d..59639d9 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/checker/VpnCheckRunner.kt @@ -9,12 +9,14 @@ import com.notcvnt.rknhardering.model.BypassResult import com.notcvnt.rknhardering.model.CdnPullingResult import com.notcvnt.rknhardering.model.CategoryResult import com.notcvnt.rknhardering.model.CheckResult +import com.notcvnt.rknhardering.model.EvidenceConfidence import com.notcvnt.rknhardering.model.EvidenceSource import com.notcvnt.rknhardering.model.Finding import com.notcvnt.rknhardering.model.GeoIpFacts import com.notcvnt.rknhardering.model.IpCheckerGroupResult import com.notcvnt.rknhardering.model.IpComparisonResult import com.notcvnt.rknhardering.model.IpConsensusResult +import com.notcvnt.rknhardering.model.LocationSignalsFacts import com.notcvnt.rknhardering.model.Verdict import com.notcvnt.rknhardering.network.DnsResolverConfig import com.notcvnt.rknhardering.probe.TunProbeModeOverride @@ -240,6 +242,61 @@ object VpnCheckRunner { ) } + private fun annotateExpectedRoamingExit( + geoIp: CategoryResult, + locationFacts: LocationSignalsFacts?, + ): CategoryResult { + val geoFacts = geoIp.geoFacts ?: return geoIp + if (locationFacts == null) return geoIp + if (!locationFacts.homeRoutedRoaming) return geoIp + val mcc = locationFacts.homeSimMcc ?: return geoIp + val profile = HomeNetworkCatalog.lookup(mcc, locationFacts.homeSimMnc) ?: return geoIp + val reason = HomeNetworkCatalog.matchExpectedExit( + profile = profile, + asn = geoFacts.asn, + isp = geoFacts.isp, + org = geoFacts.org, + ) ?: return geoIp + return geoIp.copy( + geoFacts = geoFacts.copy( + expectedRoamingExit = true, + expectedRoamingExitReason = reason, + ), + ) + } + + private fun relaxCdnPullingForHomeRoutedRoaming(result: CdnPullingResult): CdnPullingResult { + // Home-routed roaming legitimately exposes a foreign IP in CDN trace + // responses. The original detection still records it informationally, + // but it must not raise needsReview when the signal is fully explained + // by the SIM home network. + if (!result.detected && !result.needsReview) return result + return result.copy( + needsReview = false, + findings = result.findings.map { finding -> + if (finding.needsReview) finding.copy(needsReview = false) else finding + }, + ) + } + + private fun relaxIcmpSpoofingForHomeRoutedRoaming(result: CategoryResult): CategoryResult { + // The ICMP-spoofing checker assumes a Russian egress; with home-routed + // roaming the egress is intentionally abroad, so blocked targets that + // reply via ICMP cannot be treated as suspicious. + if (!result.needsReview && result.evidence.none { it.detected }) return result + return result.copy( + needsReview = false, + evidence = result.evidence.map { item -> + if (item.source == EvidenceSource.ICMP_SPOOFING && item.detected) { + item.copy(detected = false, confidence = EvidenceConfidence.LOW) + } else item + }, + findings = result.findings.map { finding -> + if (finding.needsReview) finding.copy(needsReview = false) else finding + }, + ) + } + suspend fun run( context: Context, settings: CheckSettings = CheckSettings(), @@ -447,10 +504,10 @@ object VpnCheckRunner { detected = false, ) - val geoIp = geoIpReadyDeferred?.await() ?: emptyGeoIpCategory + val rawGeoIp = geoIpReadyDeferred?.await() ?: emptyGeoIpCategory val ipComparison = ipComparisonReadyDeferred?.await() ?: emptyIpComparison - val cdnPulling = cdnPullingReadyDeferred?.await() ?: emptyCdnPulling - val icmpSpoofing = icmpSpoofingReadyDeferred?.await() ?: emptyIcmpSpoofing + val rawCdnPulling = cdnPullingReadyDeferred?.await() ?: emptyCdnPulling + val rawIcmpSpoofing = icmpSpoofingReadyDeferred?.await() ?: emptyIcmpSpoofing val rttTriangulation = rttTriangulationReadyDeferred?.await() ?: emptyRttTriangulation val directSigns = directReadyDeferred.await() val indirectSigns = indirectReadyDeferred.await() @@ -459,6 +516,19 @@ object VpnCheckRunner { val bypassResult = bypassReadyDeferred?.await() ?: emptyBypass val tunProbeResult = tunActiveProbeDeferred?.await() + // Cross-checker reconciliation: when the SIM home network is foreign + // and the visited network is in Russia, the ISP-level egress will + // legitimately appear abroad. Try to confirm via ASN match against + // GeoIP to suppress false positives in CDN/ICMP/GeoIP categories. + val geoIp = annotateExpectedRoamingExit(rawGeoIp, locationSignals.locationFacts) + val homeRoutedRoaming = locationSignals.locationFacts?.homeRoutedRoaming == true + val cdnPulling = if (homeRoutedRoaming) { + relaxCdnPullingForHomeRoutedRoaming(rawCdnPulling) + } else rawCdnPulling + val icmpSpoofing = if (homeRoutedRoaming) { + relaxIcmpSpoofingForHomeRoutedRoaming(rawIcmpSpoofing) + } else rawIcmpSpoofing + val ipConsensus = runCatching { IpConsensusBuilder.build( geoIp = geoIp, diff --git a/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt b/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt index 0049595..c6ee448 100644 --- a/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt +++ b/app/src/main/java/com/notcvnt/rknhardering/model/CheckResult.kt @@ -8,10 +8,36 @@ data class GeoIpFacts( val ip: String? = null, val countryCode: String? = null, val asn: String? = null, + val asnCode: String? = null, + val isp: String? = null, + val org: String? = null, val outsideRu: Boolean = false, val hosting: Boolean = false, val proxyDb: Boolean = false, val fetchError: Boolean = false, + val expectedRoamingExit: Boolean = false, + val expectedRoamingExitReason: String? = null, +) + +/** + * Structured signals from LocationSignalsChecker, used by VerdictEngine and the + * UI to recognise "home-routed roaming" — a foreign SIM connected via the + * Russian visited network whose data plane still exits via the home country. + */ +data class LocationSignalsFacts( + val networkMcc: String? = null, + val networkMnc: String? = null, + val networkCountryIso: String? = null, + val networkOperatorName: String? = null, + val networkIsRussia: Boolean = false, + val homeSimMcc: String? = null, + val homeSimMnc: String? = null, + val homeSimCountryIso: String? = null, + val homeSimCountryIsRussia: Boolean = false, + val homeSimOperatorName: String? = null, + val anySimReportedRoaming: Boolean = false, + val homeRoutedRoaming: Boolean = false, + val homeRoutedRoamingReason: String? = null, ) enum class EvidenceConfidence { @@ -39,6 +65,7 @@ enum class EvidenceSource { PROXY_TECHNICAL_SIGNAL, DUMPSYS, LOCATION_SIGNALS, + HOME_ROUTED_ROAMING, VPN_GATEWAY_LEAK, VPN_NETWORK_BINDING, TUN_ACTIVE_PROBE, @@ -233,6 +260,7 @@ data class CategoryResult( val callTransportLeaks: List = emptyList(), val stunProbeGroups: List = emptyList(), val geoFacts: GeoIpFacts? = null, + val locationFacts: LocationSignalsFacts? = null, ) { val hasError: Boolean get() = findings.any { it.isError } diff --git a/app/src/main/res/drawable/bg_verdict_home_routed_roaming_note.xml b/app/src/main/res/drawable/bg_verdict_home_routed_roaming_note.xml new file mode 100644 index 0000000..ab3bbb2 --- /dev/null +++ b/app/src/main/res/drawable/bg_verdict_home_routed_roaming_note.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 8b22dd9..b4c8696 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1730,6 +1730,16 @@ android:layout_width="match_parent" android:layout_height="wrap_content" /> + + نشانه‌ها ناقص یا متناقض‌اند، بنابراین بررسی دستی لازم است. هیچ نشانه تعیین‌کننده‌ای از دور زدن پیدا نشد. + یک سیم‌کارت خارجی (%1$s، %2$s) از طریق شبکه میزبان روسیه متصل است. آی‌پی عمومی متعلق به ASN اپراتور خانگی است؛ این یک رومینگ معمول از مسیر خانگی است و نه دور زدن. + یک سیم‌کارت خارجی (%1$s) از طریق شبکه میزبان روسیه متصل است. آی‌پی عمومی متعلق به ASN اپراتور خانگی است؛ این یک رومینگ معمول از مسیر خانگی است و نه دور زدن. + یک سیم‌کارت خارجی (%1$s، %2$s) از طریق شبکه میزبان روسیه متصل است. به‌احتمال زیاد جلسه داده در کشور خانگی خروج پیدا می‌کند؛ این سناریوی مشروع شبیه دور زدن به نظر می‌رسد. + یک سیم‌کارت خارجی (%1$s) از طریق شبکه میزبان روسیه متصل است. به‌احتمال زیاد جلسه داده در کشور خانگی خروج پیدا می‌کند؛ این سناریوی مشروع شبیه دور زدن به نظر می‌رسد. + یک سیم‌کارت خارجی از طریق شبکه میزبان روسیه متصل است. ممکن است جلسه داده به‌طور قانونی در خارج خروج پیدا کند (رومینگ از مسیر خانگی). + بله خیر (مالک: %1$s) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ca9ce3d..7e0e92a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -327,6 +327,12 @@ Сигналы частичные или противоречивые, поэтому нужен ручной разбор. Решающие сигналы обхода не найдены. + Иностранная SIM (%1$s, %2$s) подключена в гостевую российскую сеть. Внешний IP принадлежит ASN домашнего оператора — это штатный home-routed роуминг, а не обход. + Иностранная SIM (%1$s) подключена в гостевую российскую сеть. Внешний IP принадлежит ASN домашнего оператора — это штатный home-routed роуминг, а не обход. + Иностранная SIM (%1$s, %2$s) подключена в гостевую российскую сеть. Скорее всего, дата-сессия выходит в интернет в домашней стране — это легитимный сценарий, который выглядит как обход. + Иностранная SIM (%1$s) подключена в гостевую российскую сеть. Скорее всего, дата-сессия выходит в интернет в домашней стране — это легитимный сценарий, который выглядит как обход. + Иностранная SIM подключена в гостевую российскую сеть. Дата-сессия может законно выходить в интернет за рубежом (home-routed роуминг). + да нет diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8a02337..63a03bb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -350,6 +350,12 @@ 信号不完整或相互矛盾,因此需要人工复核。 未发现决定性的绕过迹象。 + 外国 SIM(%1$s,%2$s)通过俄罗斯访问网络接入。公网 IP 属于归属运营商的 ASN,这是正常的归属路由漫游,并非绕过。 + 外国 SIM(%1$s)通过俄罗斯访问网络接入。公网 IP 属于归属运营商的 ASN,这是正常的归属路由漫游,并非绕过。 + 外国 SIM(%1$s,%2$s)通过俄罗斯访问网络接入。数据会话很可能在归属国出口,这种合法情景看起来像绕过。 + 外国 SIM(%1$s)通过俄罗斯访问网络接入。数据会话很可能在归属国出口,这种合法情景看起来像绕过。 + 外国 SIM 通过俄罗斯访问网络接入。数据会话可能合法地在境外出口(归属路由漫游)。 + (所有者:%1$s) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42ac654..29f2bb5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -324,6 +324,12 @@ Signals are partial or contradictory, so manual review is needed. No decisive bypass signals were found. + A foreign SIM (%1$s, %2$s) is connected via a Russian visited network. The public IP belongs to the home operator\'s ASN, which is normal home-routed roaming and not a bypass. + A foreign SIM (%1$s) is connected via a Russian visited network. The public IP belongs to the home operator\'s ASN, which is normal home-routed roaming and not a bypass. + A foreign SIM (%1$s, %2$s) is connected via a Russian visited network. The data session likely exits via the home country, which can look like a bypass. + A foreign SIM (%1$s) is connected via a Russian visited network. The data session likely exits via the home country, which can look like a bypass. + A foreign SIM is connected via a Russian visited network. The data session may legitimately exit abroad (home-routed roaming). + yes no diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/BypassCheckerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/BypassCheckerTest.kt index d468f77..04ac698 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/BypassCheckerTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/BypassCheckerTest.kt @@ -596,13 +596,18 @@ class BypassCheckerTest { assertEquals(LocalProxyCheckStatus.AUTH_REQUIRED, evaluation.proxyChecks.single().status) assertTrue(BypassChecker.proxyChecksNeedReview(evaluation.proxyChecks)) assertFalse(evidence.any { it.source == EvidenceSource.SPLIT_TUNNEL_BYPASS && it.detected }) + // AUTH_REQUIRED + UNRESOLVED owner is downgraded to informational so + // the verdict isn't inflated by an unattributable listener that we + // cannot probe further. assertTrue( findings.any { it.source == EvidenceSource.LOCAL_PROXY && - it.needsReview && + it.isInformational && + !it.needsReview && !it.detected }, ) + assertFalse(evidence.any { it.source == EvidenceSource.LOCAL_PROXY }) } @Test diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/CdnPullingCheckerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/CdnPullingCheckerTest.kt index fe683ee..b955493 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/CdnPullingCheckerTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/CdnPullingCheckerTest.kt @@ -124,7 +124,8 @@ class CdnPullingCheckerTest { ) assertTrue(result.detected) - assertTrue(result.needsReview) + // No IP conflict — partial trace data alone is informational. + assertFalse(result.needsReview) assertEquals( context.getString(R.string.checker_cdn_pulling_summary_detected_no_ip, 2, 2), result.summary, @@ -132,6 +133,32 @@ class CdnPullingCheckerTest { assertTrue(result.findings.any { it.description == "meduza.io: IP: 203.0.113.64, LOC: FI" }) } + @Test + fun `evaluate raises review when actionable IPs disagree`() { + val result = CdnPullingChecker.evaluate( + context = context, + responses = listOf( + CdnPullingResponse( + targetLabel = "meduza.io", + url = "https://meduza.io/cdn-cgi/trace", + ip = "203.0.113.64", + ipv4 = "203.0.113.64", + importantFields = linkedMapOf("IP" to "203.0.113.64"), + ), + CdnPullingResponse( + targetLabel = "redirector.googlevideo.com", + url = "https://redirector.googlevideo.com/report_mapping?di=no", + ip = "198.51.100.42", + ipv4 = "198.51.100.42", + importantFields = linkedMapOf("IP" to "198.51.100.42"), + ), + ), + ) + + assertTrue(result.detected) + assertTrue(result.needsReview) + } + @Test fun `fetchBodyWithRetries retries transient failures and returns success`() = runBlocking { var attempts = 0 diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/LocationSignalsCheckerTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/LocationSignalsCheckerTest.kt index d04612d..8394efd 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/LocationSignalsCheckerTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/LocationSignalsCheckerTest.kt @@ -4,6 +4,7 @@ import com.notcvnt.rknhardering.model.EvidenceConfidence import com.notcvnt.rknhardering.model.EvidenceSource import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test @@ -375,8 +376,11 @@ class LocationSignalsCheckerTest { networkCountryIso = "gr", networkOperatorName = "Cosmote", simCards = listOf( - sim(slotIndex = 0, subscriptionId = 1, simMcc = "250", simCountryIso = "ru", operatorName = "MegaFon", isRoaming = false), - sim(slotIndex = 1, subscriptionId = 2, simMcc = "202", simCountryIso = "gr", operatorName = "Cosmote", isRoaming = false), + // Home SIM is foreign (GR) and matches the visited (GR) network — no + // Russian SIM means we cannot blame the geo signal on home-routed + // roaming, so confidence stays at MEDIUM. + sim(slotIndex = 0, subscriptionId = 1, simMcc = "202", simCountryIso = "gr", operatorName = "Cosmote", isRoaming = false), + sim(slotIndex = 1, subscriptionId = 2, simMcc = "208", simCountryIso = "fr", operatorName = "Orange", isRoaming = false), ), ), ) @@ -413,6 +417,39 @@ class LocationSignalsCheckerTest { ) } + @Test + fun `foreign sim on russian visited network is flagged as home-routed roaming`() { + val result = LocationSignalsChecker.evaluate( + snapshot( + networkMcc = "250", + networkCountryIso = "ru", + networkOperatorName = "MegaFon", + simCards = listOf( + sim( + slotIndex = 0, + subscriptionId = 1, + simMcc = "208", + simCountryIso = "fr", + operatorName = "Free Mobile", + isRoaming = false, + ), + ), + ), + ) + + assertFalse(result.needsReview) + assertFalse(result.detected) + assertNotNull(result.locationFacts) + assertTrue(result.locationFacts!!.homeRoutedRoaming) + assertEquals("208", result.locationFacts!!.homeSimMcc) + assertEquals("FR", result.locationFacts!!.homeSimCountryIso) + assertTrue(result.findings.any { it.description.startsWith("home_routed_roaming:true") }) + assertTrue(result.evidence.any { it.source == EvidenceSource.HOME_ROUTED_ROAMING }) + // SIM-MCC mismatch flips the displayed roaming state to "yes" even when + // telephony reported it as "no" (the bug from issue #63). + assertTrue(result.findings.any { it.description == "SIM[0] Roaming: yes" }) + } + @Test fun `empty sim cards list produces no sim findings and medium confidence for non-ru network`() { val result = LocationSignalsChecker.evaluate( diff --git a/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt b/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt index 29d30fe..a1e451b 100644 --- a/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt +++ b/app/src/test/java/com/notcvnt/rknhardering/checker/VerdictEngineTest.kt @@ -14,6 +14,7 @@ import com.notcvnt.rknhardering.model.EvidenceSource import com.notcvnt.rknhardering.model.Finding import com.notcvnt.rknhardering.model.GeoIpFacts import com.notcvnt.rknhardering.model.IpConsensusResult +import com.notcvnt.rknhardering.model.LocationSignalsFacts import com.notcvnt.rknhardering.model.Verdict import org.junit.Assert.assertEquals import org.junit.Test @@ -167,6 +168,69 @@ class VerdictEngineTest { assertEquals(Verdict.DETECTED, verdict) } + @Test + fun `home-routed roaming with foreign sim suppresses R4 detect`() { + val geo = category( + geoFacts = GeoIpFacts( + outsideRu = true, + countryCode = "FR", + expectedRoamingExit = true, + ), + ) + val location = CategoryResult( + name = "loc", + detected = false, + findings = listOf(Finding("network_mcc_ru:true")), + locationFacts = LocationSignalsFacts( + networkMcc = "250", + networkIsRussia = true, + homeSimMcc = "208", + homeSimCountryIso = "FR", + homeRoutedRoaming = true, + ), + ) + val verdict = VerdictEngine.evaluate( + geoIp = geo, + directSigns = category(), + indirectSigns = category(), + locationSignals = location, + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + ) + // Should fall through to NOT_DETECTED — this is the issue #63 case. + assertEquals(Verdict.NOT_DETECTED, verdict) + } + + @Test + fun `home-routed roaming without ASN match still suppresses verdict`() { + // Even without GeoIP ASN confirmation, the home-routed-roaming flag + // alone is enough to gate R4 — the fallback for unknown carriers. + val geo = category( + geoFacts = GeoIpFacts(outsideRu = true, countryCode = "FR"), + ) + val location = CategoryResult( + name = "loc", + detected = false, + findings = listOf(Finding("network_mcc_ru:true")), + locationFacts = LocationSignalsFacts( + networkMcc = "250", + networkIsRussia = true, + homeSimMcc = "208", + homeSimCountryIso = "FR", + homeRoutedRoaming = true, + ), + ) + val verdict = VerdictEngine.evaluate( + geoIp = geo, + directSigns = category(), + indirectSigns = category(), + locationSignals = location, + bypassResult = bypass(), + ipConsensus = IpConsensusResult.empty(), + ) + assertEquals(Verdict.NOT_DETECTED, verdict) + } + @Test fun `R4 regression issue 15 hosting only yields needs review`() { val geo = category( @@ -519,14 +583,17 @@ class VerdictEngineTest { needsReview: Boolean = false, callTransportLeaks: List = emptyList(), geoFacts: GeoIpFacts? = null, + findings: List = emptyList(), + locationFacts: LocationSignalsFacts? = null, ): CategoryResult = CategoryResult( name = "test", detected = evidence.any { it.detected }, - findings = emptyList(), + findings = findings, needsReview = needsReview, evidence = evidence, callTransportLeaks = callTransportLeaks, geoFacts = geoFacts, + locationFacts = locationFacts, ) private fun bypass( diff --git a/fastlane/metadata/android/en-US/changelogs/20701.txt b/fastlane/metadata/android/en-US/changelogs/20701.txt new file mode 100644 index 0000000..15506fa --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20701.txt @@ -0,0 +1,8 @@ +Fixed false-positive verdict for foreign SIM roaming in Russia (issue #63): home-routed roaming is now recognised by MCC and the home operator's ASN. +Added an explanatory banner to the verdict card when home-routed roaming is detected. +Fixed roaming status heuristic: "Roaming: yes" is now shown when SIM MCC mismatches network MCC, even if telephony reported "no". +Fixed SIM country label: foreign SIMs on a Russian visited network no longer show as RU. +CDN pulling no longer flags "needs review" when all targets returned the same IP. +Local SOCKS5 endpoints requiring auth with no resolved owner no longer inflate the verdict. +JSON export now includes roaming diagnostics and richer GeoIP facts (ASN code, ISP, organisation). +More information in data export. diff --git a/fastlane/metadata/android/fa/changelogs/20701.txt b/fastlane/metadata/android/fa/changelogs/20701.txt new file mode 100644 index 0000000..8a530d2 --- /dev/null +++ b/fastlane/metadata/android/fa/changelogs/20701.txt @@ -0,0 +1,8 @@ +رفع نتیجه مثبت کاذب در رومینگ سیم‌کارت خارجی در روسیه (issue #63): رومینگ از مسیر خانگی اکنون بر اساس MCC و ASN اپراتور خانگی شناسایی می‌شود. +در کارت حکم هنگام تشخیص رومینگ از مسیر خانگی یک پیام توضیحی افزوده شد. +رفع منطق وضعیت رومینگ: اکنون وقتی MCC سیم با MCC شبکه ناهمخوان باشد «Roaming: yes» نمایش داده می‌شود، حتی اگر تلفن «no» گزارش کند. +رفع برچسب کشور سیم: سیم‌های خارجی در شبکه میزبان روسیه دیگر به‌عنوان RU نمایش داده نمی‌شوند. +وقتی همه اهداف CDN یک IP یکسان برگردانند، CDN pulling دیگر با «نیاز به بررسی» علامت نمی‌خورد. +نقاط پایانی SOCKS5 محلی نیازمند احراز هویت بدون مالک شناسایی‌شده دیگر حکم را بزرگ‌نمایی نمی‌کنند. +خروجی JSON اکنون شامل تشخیص رومینگ و اطلاعات بیشتر GeoIP (کد ASN، ISP، سازمان) است. +اطلاعات بیشتر در خروجی داده. diff --git a/fastlane/metadata/android/ru-RU/changelogs/20701.txt b/fastlane/metadata/android/ru-RU/changelogs/20701.txt new file mode 100644 index 0000000..46962f8 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/20701.txt @@ -0,0 +1,8 @@ +Исправлено ложноположительное срабатывание при роуминге иностранной SIM в России (issue #63): home-routed роуминг теперь распознаётся по MCC и ASN домашнего оператора. +В карточку вердикта добавлен пояснительный баннер при home-routed роуминге. +Исправлена эвристика статуса роуминга: «Roaming: yes» теперь показывается, когда MCC SIM не совпадает с MCC сети, даже если телефония вернула «no». +Исправлена страна SIM в отчёте: больше не показывается RU для иностранных SIM в российской сети. +CDN pulling больше не помечается «требует проверки», когда все цели вернули один и тот же IP. +Локальные SOCKS5 с обязательной авторизацией без определённого владельца больше не раздувают вердикт. +В JSON-экспорт добавлена диагностика роуминга и расширены геофакты (ASN-код, ISP, организация). +Больше информации в экспорте данных. diff --git a/fastlane/metadata/android/zh-CN/changelogs/20701.txt b/fastlane/metadata/android/zh-CN/changelogs/20701.txt new file mode 100644 index 0000000..a3b11e3 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/20701.txt @@ -0,0 +1,8 @@ +修复在俄罗斯境内使用外国 SIM 漫游时的误报(issue #63):现在通过 MCC 和归属运营商 ASN 识别归属路由漫游。 +当检测到归属路由漫游时,在裁决卡片中添加说明性提示。 +修复漫游状态判定:当 SIM MCC 与网络 MCC 不一致时,即使电话子系统返回 "no",也会显示 "Roaming: yes"。 +修复 SIM 国家标签:在俄罗斯访问网络下的外国 SIM 不再显示为 RU。 +当所有 CDN 目标返回相同 IP 时,CDN pulling 不再标记为 "需要复核"。 +需要鉴权且无法解析所有者的本地 SOCKS5 端点不再夸大裁决。 +JSON 导出新增漫游诊断及更丰富的 GeoIP 信息(ASN 代码、ISP、组织)。 +数据导出包含更多信息。