diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8f5e46c..fa5410e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,3 @@ - plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -84,7 +83,10 @@ android { } } +@Suppress("UNCHECKED_CAST") fun getVersionName(): String = (project.extra["getVersionName"] as () -> String)() + +@Suppress("UNCHECKED_CAST") fun getVersionCode(): Int = (project.extra["getVersionCode"] as () -> Int)() dependencies { @@ -114,7 +116,6 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.androidx.ui.test.junit4.android) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5cb59ae..3842859 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,6 @@ androidCoreKtx = "1.17.0" androidLifecycleRuntimeKtx = "2.9.4" appcompat = "1.7.1" bouncycastle = "1.79" -compose = "1.7.6" composeBom = "2025.09.00" compose-material3 = "1.3.1" composePayButton = "1.1.0" @@ -16,7 +15,7 @@ coilNetworkOkhttp = "3.3.0" coilTest = "3.3.0" datastorePreferences = "1.1.7" dokka = "2.0.0" -espressoCore = "3.5.0" +espressoCore = "3.7.0" firebaseBom = "34.4.0" google-services-plugin = "4.4.4" io-mockk = "1.14.6" @@ -31,8 +30,6 @@ kotlinxSerializationJson = "1.9.0" playServicesWallet = "19.4.0" slf4jApi = "2.0.17" sonarqube-plugin = "6.3.1.5724" -uiTooling = "1.9.1" -uiTestJunit4Android = "1.9.3" runtime = "1.0.0-alpha06" junitKtx = "1.3.0" @@ -49,10 +46,10 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidLifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidLifecycleRuntimeKtx" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "compose-material3" } -androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" } -androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "compose" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk15to18", version.ref = "bouncycastle" } @@ -68,14 +65,13 @@ kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-cor kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } play-services-wallet = { module = "com.google.android.gms:play-services-wallet", version.ref = "playServicesWallet" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4jApi" } -ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } -androidx-ui-test-junit4-android = { group = "androidx.compose.ui", name = "ui-test-junit4-android", version.ref = "uiTestJunit4Android" } androidx-runtime = { group = "androidx.xr.runtime", name = "runtime", version.ref = "runtime" } androidx-junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junitKtx" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } io-coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } io-coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } io-coil-test = { module = "io.coil-kt.coil3:coil-test", version.ref = "coilTest" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } @@ -86,5 +82,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-parcelize = { id = "kotlin-parcelize" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization" } -sonarqube = { id = "org.sonarqube", version.ref = "sonarqube-plugin" } - +sonarqube = { id = "org.sonarqube", version.ref = "sonarqube-plugin" } \ No newline at end of file diff --git a/monext/build.gradle.kts b/monext/build.gradle.kts index 6f57c27..9d44444 100644 --- a/monext/build.gradle.kts +++ b/monext/build.gradle.kts @@ -133,7 +133,14 @@ android { } } -// Fonction pour récupérer la clé API de manière sécurisée +// Force la résolution d'espresso-core en 3.7.0 même si une lib transitive +// tente de le tirer en 3.6.x (incompatible avec Android 15+). +configurations.all { + resolutionStrategy { + force("androidx.test.espresso:espresso-core:3.7.0") + } +} + fun getApiKey(): String { // 1. Variable d'environnement (CI/CD) System.getenv("THREEDS_API_ACCESS_KEY")?.let { @@ -469,18 +476,22 @@ dependencies { // Voir la documentation : https://3dss.netcetera.com/3dssdk/doc/2.25.0/android-integration implementation(files("libs/netcetera-3ds-sdk-2.5.3.2-classes.jar")) - // Tests + // Tests JVM testImplementation(libs.junit.jupiter) testImplementation(kotlin("test")) testImplementation(libs.io.mockk) testImplementation(libs.jetbrains.kotlinx.test) testImplementation(libs.slf4j.api) - debugImplementation(libs.ui.tooling) + // Tooling Compose (Preview en debug) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + // Tests instrumentés + androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.androidx.ui.test.junit4.android) + androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.io.mockk) androidTestImplementation(libs.io.mockk.agent) androidTestImplementation(libs.io.mockk.android) diff --git a/monext/src/androidTest/kotlin/com/monext/sdk/internal/security/RootDetectionRealDeviceTest.kt b/monext/src/androidTest/kotlin/com/monext/sdk/internal/security/RootDetectionRealDeviceTest.kt new file mode 100644 index 0000000..4701d4e --- /dev/null +++ b/monext/src/androidTest/kotlin/com/monext/sdk/internal/security/RootDetectionRealDeviceTest.kt @@ -0,0 +1,50 @@ +package com.monext.sdk.internal.security + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.monext.sdk.internal.security.check.BinaryCheck +import com.monext.sdk.internal.security.check.BuildCheck +import com.monext.sdk.internal.security.check.DebuggerCheck +import com.monext.sdk.internal.security.check.FridaCheck +import com.monext.sdk.internal.security.check.PackageCheck +import com.monext.sdk.internal.security.check.ProcessCheck +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RootDetectionRealDeviceTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun buildCheck_shouldTriggerOnEmulator() { + assertTrue(BuildCheck().check()) + } + + @Test + fun binaryCheck_shouldNotTriggerOnCleanEmulator() { + assertTrue(BinaryCheck().check()) + } + + @Test + fun packageCheck_shouldNotTriggerOnCleanEmulator() { + assertFalse(PackageCheck(context.packageManager).check()) + } + + @Test + fun processCheck_shouldNotTriggerOnCleanEmulator() { + assertFalse(ProcessCheck(context).check()) + } + + @Test + fun fridaCheck_shouldNotTriggerOnCleanEmulator() { + assertFalse(FridaCheck().check()) + } + + @Test + fun debuggerCheck_jdwpOnly_shouldNotTrigger() { + assertFalse(DebuggerCheck().detectJavaDebugger()) + } +} \ No newline at end of file diff --git a/monext/src/androidTest/kotlin/com/monext/sdk/internal/security/ui/SecurityAlertDialogTest.kt b/monext/src/androidTest/kotlin/com/monext/sdk/internal/security/ui/SecurityAlertDialogTest.kt new file mode 100644 index 0000000..654e8ee --- /dev/null +++ b/monext/src/androidTest/kotlin/com/monext/sdk/internal/security/ui/SecurityAlertDialogTest.kt @@ -0,0 +1,86 @@ +package com.monext.sdk.internal.security.ui + +import android.os.StrictMode +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.monext.sdk.FakeTestActivity +import io.mockk.MockKAnnotations +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SecurityAlertDialogTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Before + fun setup() { + MockKAnnotations.init(this) + StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.LAX) + StrictMode.setVmPolicy(StrictMode.VmPolicy.LAX) + } + + private fun setDialog(onDismiss: () -> Unit) { + composeTestRule.activity.setTestComposable { + CompositionLocalProvider( + LocalActivity provides composeTestRule.activity + ) { + SecurityAlertDialog(onDismiss = onDismiss) + } + } + composeTestRule.waitForIdle() + } + + @Test + fun securityDialog_whenShowing_displaysTitle() { + setDialog(onDismiss = {}) + + composeTestRule + .onNodeWithText("Appareil non sécurisé") + .assertIsDisplayed() + } + + @Test + fun securityDialog_whenShowing_displaysMessage() { + setDialog(onDismiss = {}) + + composeTestRule + .onNodeWithText( + "Votre appareil ne répond pas aux exigences de sécurité " + + "requises pour effectuer un paiement." + ) + .assertIsDisplayed() + } + + @Test + fun securityDialog_whenShowing_displaysCloseButton() { + setDialog(onDismiss = {}) + + composeTestRule + .onNodeWithText("Fermer") + .assertIsDisplayed() + } + + @Test + fun securityDialog_whenCloseButtonClicked_callsOnDismiss() { + var dismissed = false + + setDialog(onDismiss = { dismissed = true }) + + composeTestRule + .onNodeWithText("Fermer") + .performClick() + + composeTestRule.waitUntil(5000) { dismissed } + assertTrue(dismissed) + } +} \ No newline at end of file diff --git a/monext/src/main/AndroidManifest.xml b/monext/src/main/AndroidManifest.xml index a5918e6..a9ab765 100644 --- a/monext/src/main/AndroidManifest.xml +++ b/monext/src/main/AndroidManifest.xml @@ -1,4 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/PaymentBox.kt b/monext/src/main/kotlin/com/monext/sdk/PaymentBox.kt index 0c02cab..8d84b13 100644 --- a/monext/src/main/kotlin/com/monext/sdk/PaymentBox.kt +++ b/monext/src/main/kotlin/com/monext/sdk/PaymentBox.kt @@ -11,6 +11,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import com.monext.sdk.internal.security.RootDetector +import com.monext.sdk.internal.security.ui.SecurityAlertDialog /** * This composable handles payment sheet presentation upon user interaction. @@ -28,10 +30,15 @@ fun PaymentBox( ) { var showPaymentSheet by rememberSaveable { mutableStateOf(false) } + var showSecurityAlert by rememberSaveable { mutableStateOf(false) } val onClick: () -> Unit = { if (sessionToken != null) { - showPaymentSheet = true + if (RootDetector.isCompromised()) { + showSecurityAlert = true + } else { + showPaymentSheet = true + } } } @@ -44,6 +51,13 @@ fun PaymentBox( sdkContext = sdkContext, onResult = onResult, onIsShowingChange = { showPaymentSheet = it }) + + + if (showSecurityAlert) { + SecurityAlertDialog( + onDismiss = { showSecurityAlert = false } + ) + } } } diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/RootDetector.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/RootDetector.kt new file mode 100644 index 0000000..84c3292 --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/RootDetector.kt @@ -0,0 +1,100 @@ +package com.monext.sdk.internal.security + +import android.content.Context +import android.util.Log + +/** + * Point d'entrée public pour la détection de root du SDK Monext. + * + * Cette classe est un singleton initialisé automatiquement au démarrage + * de l'application via [RootDetectorInitializer] (ContentProvider). + * Aucun appel manuel à [init] n'est nécessaire côté application intégratrice. + * + * ### Usage + * ```kotlin + * if (RootDetector.isCompromised()) { + * // Bloquer l'action sensible + * } + * ``` + * + * ### Cycle de vie + * - **Init** : déclenché automatiquement par [RootDetectorInitializer] au boot de l'app. + * - **Résultat** : mis en cache, recalculé toutes les [CACHE_TTL_MS] millisecondes. + * - **Thread-safety** : le champ [compromised] est marqué `@Volatile`. + */ +object RootDetector { + + private const val TAG = "RootDetector" + + /** + * Durée de validité du cache en millisecondes (30 secondes). + * Évite de relancer tous les checks à chaque appel. + */ + private const val CACHE_TTL_MS = 30_000L + + @Volatile private var initialized = false + @Volatile private var compromised = false + @Volatile private var lastCheckTime = 0L + + private var detectorImpl: RootDetectorImpl? = null + + /** + * Initialise le détecteur avec le contexte de l'application. + * + * Appelé automatiquement par [RootDetectorInitializer]. + * Un second appel est ignoré (idempotent). + * + * @param context Le contexte de l'application (ApplicationContext). + */ + internal fun init(context: Context) { + if (initialized) return + + detectorImpl = RootDetectorImpl(context.applicationContext) + refreshCheck() + initialized = true + + Log.d(TAG, "RootDetector initialized — compromised=$compromised") + } + + /** + * Indique si l'appareil est considéré comme compromis. + * + * Le résultat est mis en cache pendant [CACHE_TTL_MS] ms. + * Si [init] n'a pas encore été appelé, retourne `false` par défaut (fail-open). + * + * @return `true` si un indicateur de root ou de compromission a été détecté. + */ + fun isCompromised(): Boolean { + if (!initialized) { + Log.w(TAG, "isCompromised() called before init()") + return false + } + + val now = System.currentTimeMillis() + if (now - lastCheckTime > CACHE_TTL_MS) { + refreshCheck() + } + + return compromised + } + + /** + * Force un recalcul immédiat de l'état de sécurité, en ignorant le cache. + * + * À utiliser avec précaution car les checks natifs peuvent être coûteux. + * + * @return `true` si l'appareil est compromis après recalcul. + */ + fun forceRefresh(): Boolean { + refreshCheck() + return compromised + } + + /** + * Recalcule l'état de sécurité et met à jour le cache. + */ + private fun refreshCheck() { + compromised = detectorImpl?.isDeviceCompromised() ?: false + lastCheckTime = System.currentTimeMillis() + } +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/RootDetectorImpl.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/RootDetectorImpl.kt new file mode 100644 index 0000000..83895c0 --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/RootDetectorImpl.kt @@ -0,0 +1,42 @@ +package com.monext.sdk.internal.security + +import android.content.Context +import com.monext.sdk.BuildConfig +import com.monext.sdk.internal.security.check.BinaryCheck +import com.monext.sdk.internal.security.check.BuildCheck +import com.monext.sdk.internal.security.check.DebuggerCheck +import com.monext.sdk.internal.security.check.FridaCheck +import com.monext.sdk.internal.security.check.PackageCheck +import com.monext.sdk.internal.security.check.ProcessCheck +import com.monext.sdk.internal.security.check.SecurityCheck + +/** + * Implémentation interne du détecteur de root. + * + * Orchestre l'ensemble des [SecurityCheck] et retourne `true` + * dès qu'un indicateur de compromission est détecté (court-circuit). + * + * Cette classe est à usage interne uniquement. L'API publique + * est exposée via [RootDetector]. + * + * @param context Le contexte Android (doit être le ApplicationContext). + */ +internal class RootDetectorImpl(context: Context) { + + private val checks: List = listOf( + BinaryCheck(), + PackageCheck(context.packageManager), + BuildCheck(), + ProcessCheck(context), + DebuggerCheck(), + FridaCheck(), + ) + + /** + * Exécute tous les checks de sécurité. + * + * @return `true` si au moins un check détecte une compromission. + */ + fun isDeviceCompromised(): Boolean = + checks.any { it.check() } +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/RootDetectorInitializer.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/RootDetectorInitializer.kt new file mode 100644 index 0000000..f7afe00 --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/RootDetectorInitializer.kt @@ -0,0 +1,49 @@ +package com.monext.sdk.internal.security + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri + +/** + * Initialise automatiquement [RootDetector] au démarrage de l'application. + * + * Ce [ContentProvider] est déclaré dans le manifest du SDK et fusionné + * automatiquement dans le manifest de l'application intégratrice lors + * de la compilation. Aucune configuration n'est requise côté intégrateur. + * + * ### Mécanisme + * Android instancie tous les [ContentProvider] déclarés avant d'appeler + * [android.app.Application.onCreate]. Cela garantit que [RootDetector] + * est initialisé avant toute interaction utilisateur. + * + * ### Inspiration + * Ce pattern est utilisé par Firebase (`FirebaseInitProvider`), + * Timber, et Jetpack App Startup. + * + * @see RootDetector + */ +class RootDetectorInitializer : ContentProvider() { + + /** + * Point d'initialisation automatique. + * + * Appelé par Android avant [android.app.Application.onCreate]. + * Initialise [RootDetector] avec l'ApplicationContext. + * + * @return `true` si l'initialisation s'est bien déroulée. + */ + override fun onCreate(): Boolean { + context?.applicationContext?.let { appContext -> + RootDetector.init(appContext) + } + return true + } + + // ContentProvider stubs — non utilisés + override fun query(uri: Uri, p: Array?, s: String?, sA: Array?, so: String?): Cursor? = null + override fun getType(uri: Uri): String? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, s: String?, sA: Array?): Int = 0 + override fun update(uri: Uri, v: ContentValues?, s: String?, sA: Array?): Int = 0 +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/check/BinaryCheck.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/BinaryCheck.kt new file mode 100644 index 0000000..1fb5bbe --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/BinaryCheck.kt @@ -0,0 +1,62 @@ +package com.monext.sdk.internal.security.check + +import com.monext.sdk.internal.security.check.SecurityCheck +import java.io.File + +class BinaryCheck : SecurityCheck { + + /** + * Liste des chemins de binaires et fichiers typiquement présents sur un appareil rooté. + * + * Inclut les chemins pour `su`, `busybox`, `daemonsu` et les fichiers + * associés à SuperSU ou Superuser. + */ + private val rootBinaryPaths = listOf( + "/system/app/Superuser.apk", + "/system/etc/init.d/99SuperSUDaemon", + "/dev/com.koushikdutta.superuser.daemon/", + "/system/xbin/daemonsu", + "/sbin/su", + "/system/bin/su", + "/system/bin/failsafe/su", + "/system/xbin/su", + "/system/xbin/busybox", + "/system/sd/xbin/su", + "/data/local/su", + "/data/local/xbin/su", + "/data/local/bin/su" + ) + + /** + * Exécute les vérifications de présence de binaires root. + * + * Vérifie d'abord les chemins connus, puis parcourt les répertoires + * du `PATH` système à la recherche du binaire `su`. + * + * @return `true` si un binaire root est détecté, `false` sinon. + */ + override fun check(): Boolean = + checkKnownPaths() || checkSuOnPath() + + /** + * Vérifie l'existence des chemins de binaires connus. + * + * @return `true` si au moins un fichier de la liste existe sur l'appareil. + */ + private fun checkKnownPaths(): Boolean = + rootBinaryPaths.any { path -> File(path).exists() } + + /** + * Vérifie si le binaire `su` est accessible via les répertoires du `PATH` système. + * + * Parcourt chaque répertoire défini dans la variable d'environnement `PATH` + * et recherche la présence d'un exécutable `su`. + * + * @return `true` si `su` est trouvé dans l'un des répertoires du `PATH`. + */ + private fun checkSuOnPath(): Boolean = + System.getenv("PATH") + ?.split(":") + ?.any { dir -> File(dir, "su").exists() } + ?: false +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/check/BuildCheck.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/BuildCheck.kt new file mode 100644 index 0000000..4019d08 --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/BuildCheck.kt @@ -0,0 +1,69 @@ +package com.monext.sdk.internal.security.check + +import android.os.Build +import com.monext.sdk.internal.security.check.SecurityCheck + +class BuildCheck : SecurityCheck { + + /** + * Exécute l'ensemble des vérifications liées au build système. + * + * @return `true` si au moins un indicateur suspect est détecté, `false` sinon. + */ + override fun check(): Boolean = + detectTestKeys() || detectCustomRom() || detectEmulator() + + /** + * Vérifie si le build est signé avec des clés de test plutôt que des clés de release. + * + * Les builds officiels Android sont signés avec `release-keys`. La présence + * de `test-keys` dans [Build.TAGS] est un indicateur fort de ROM custom ou rootée. + * + * @return `true` si [Build.TAGS] contient `test-keys`. + */ + private fun detectTestKeys(): Boolean = + Build.TAGS?.contains("test-keys") == true + + /** + * Vérifie si le fingerprint du build indique une ROM custom ou non officielle. + * + * Analyse [Build.FINGERPRINT] à la recherche de sous-chaînes caractéristiques + * des builds non officiels. + * + * @return `true` si le fingerprint contient `custom` ou `test-keys`. + */ + private fun detectCustomRom(): Boolean { + val fingerprint = Build.FINGERPRINT.lowercase() + return fingerprint.contains("custom") || fingerprint.contains("test-keys") + } + + /** + * Vérifie si l'application s'exécute sur un émulateur Android. + * + * Inspecte plusieurs propriétés du build pour détecter les émulateurs + * courants (Android SDK Emulator, Genymotion, etc.). + * + * Les indicateurs vérifiés incluent : + * - [Build.PRODUCT] : valeurs comme `sdk`, `simulator`, `vbox86p` + * - [Build.HARDWARE] : valeurs comme `goldfish`, `ranchu` + * - [Build.MANUFACTURER] : `Genymotion` + * - [Build.MODEL] : `Emulator`, `Android SDK built for x86` + * - [Build.BRAND] : `generic` + * + * @return `true` si l'appareil est identifié comme un émulateur. + */ + private fun detectEmulator(): Boolean { + val emulatorIndicators = listOf( + Build.PRODUCT.contains("sdk"), + Build.PRODUCT.contains("simulator"), + Build.PRODUCT.contains("vbox86p"), + Build.HARDWARE.contains("goldfish"), + Build.HARDWARE.contains("ranchu"), + Build.MANUFACTURER.equals("Genymotion", ignoreCase = true), + Build.MODEL.contains("Emulator", ignoreCase = true), + Build.MODEL.contains("Android SDK built for x86", ignoreCase = true), + Build.BRAND.equals("generic", ignoreCase = true) + ) + return emulatorIndicators.any { it } + } +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/check/DebuggerCheck.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/DebuggerCheck.kt new file mode 100644 index 0000000..e2a2f84 --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/DebuggerCheck.kt @@ -0,0 +1,61 @@ +package com.monext.sdk.internal.security.check + +import android.os.Debug +import com.monext.sdk.internal.security.check.SecurityCheck + +class DebuggerCheck : SecurityCheck { + + companion object { + /** + * Seuil en nanosecondes au-delà duquel le temps CPU de la boucle de référence + * est considéré comme anormalement élevé, indiquant la présence d'un débogueur. + * + * Valeur : 10 ms (10 000 000 ns). + */ + private const val TIMING_THRESHOLD_NS = 10_000_000L + + /** + * Nombre d'itérations de la boucle de référence utilisée pour la détection par timing. + */ + private const val TIMING_LOOP_ITERATIONS = 1_000_000 + } + + /** + * Exécute les vérifications de présence d'un débogueur. + * + * @return `true` si un débogueur est détecté, `false` sinon. + */ + override fun check(): Boolean = + detectJavaDebugger() || detectDebuggerByTiming() + + /** + * Vérifie si un débogueur Java (JDWP) est actuellement connecté ou en attente. + * + * Utilise les méthodes standard du SDK Android pour interroger l'état du débogueur : + * - [Debug.isDebuggerConnected] : `true` si un débogueur est actif. + * - [Debug.waitingForDebugger] : `true` si l'application attend une connexion. + * + * @return `true` si un débogueur Java est détecté. + */ + fun detectJavaDebugger(): Boolean = + Debug.isDebuggerConnected() || Debug.waitingForDebugger() + + /** + * Détecte la présence d'un débogueur par analyse du temps d'exécution (timing attack). + * + * Mesure le temps CPU nécessaire pour exécuter une boucle de référence de + * [TIMING_LOOP_ITERATIONS] itérations. En présence d'un débogueur, le temps + * d'exécution est significativement supérieur au seuil [TIMING_THRESHOLD_NS]. + * + * Cette technique est complémentaire à [detectJavaDebugger] car elle peut + * détecter certains débogueurs qui masquent leur présence aux APIs standard. + * + * @return `true` si le temps CPU dépasse le seuil, indiquant un débogueur probable. + */ + fun detectDebuggerByTiming(): Boolean { + val start = Debug.threadCpuTimeNanos() + repeat(TIMING_LOOP_ITERATIONS) { /* boucle de référence */ } + val elapsed = Debug.threadCpuTimeNanos() - start + return elapsed >= TIMING_THRESHOLD_NS + } +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/check/FridaCheck.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/FridaCheck.kt new file mode 100644 index 0000000..1b712c2 --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/FridaCheck.kt @@ -0,0 +1,75 @@ +package com.monext.sdk.internal.security.check + +import com.monext.sdk.internal.security.check.SecurityCheck +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.InetSocketAddress +import java.net.Socket + +class FridaCheck : SecurityCheck { + + companion object { + /** + * Port réseau par défaut sur lequel Frida Server écoute les connexions. + */ + private const val FRIDA_DEFAULT_PORT = 27042 + + /** + * Adresse localhost utilisée pour tenter la connexion au serveur Frida. + */ + private const val LOCALHOST = "127.0.0.1" + + /** + * Délai maximum en millisecondes pour la tentative de connexion au port Frida. + * Une valeur faible limite l'impact sur les performances. + */ + private const val CONNECTION_TIMEOUT_MS = 300 + } + + /** + * Exécute les vérifications de présence de Frida. + * + * @return `true` si Frida est détecté via le port ou les processus, `false` sinon. + */ + override fun check(): Boolean = + isFridaPortOpen() || isFridaProcessRunning() + + /** + * Tente une connexion TCP sur le port par défaut de Frida Server ([FRIDA_DEFAULT_PORT]). + * + * Si la connexion aboutit, cela indique qu'un serveur Frida est actif sur l'appareil. + * La connexion est fermée immédiatement après détection. + * + * @return `true` si la connexion au port Frida réussit, `false` sinon ou en cas d'erreur. + */ + fun isFridaPortOpen(): Boolean = runCatching { + Socket().use { socket -> + socket.connect( + InetSocketAddress(LOCALHOST, FRIDA_DEFAULT_PORT), + CONNECTION_TIMEOUT_MS + ) + true + } + }.getOrDefault(false) + + /** + * Recherche le processus Frida parmi les processus actifs via la commande `ps`. + * + * Exécute `ps` et analyse la sortie ligne par ligne à la recherche de marqueurs + * caractéristiques de Frida (`frida`, `frida-server`). + * + * > **Note** : la commande `ps` peut être inaccessible sur Android 9+ selon + * > les restrictions SELinux en vigueur. En cas d'échec, la méthode retourne `false` + * > sans lever d'exception. + * + * @return `true` si un processus Frida est trouvé, `false` sinon ou en cas d'erreur. + */ + fun isFridaProcessRunning(): Boolean = runCatching { + val process = Runtime.getRuntime().exec("ps") + BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + reader.lineSequence().any { line -> + line.contains("frida", ignoreCase = true) + } + } + }.getOrDefault(false) +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/check/PackageCheck.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/PackageCheck.kt new file mode 100644 index 0000000..b41ab2b --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/PackageCheck.kt @@ -0,0 +1,49 @@ +package com.monext.sdk.internal.security.check + +import android.content.pm.PackageManager +import com.monext.sdk.internal.security.check.SecurityCheck + +class PackageCheck(private val packageManager: PackageManager) : SecurityCheck { + + /** + * Liste des noms de packages associés aux applications de root courantes. + * + * Inclut Magisk, SuperSU, Superuser et leurs variantes connues. + */ + private val knownRootPackages = listOf( + "com.topjohnwu.magisk", + "eu.chainfire.supersu", + "com.noshufou.android.su", + "com.noshufou.android.su.elite", + "com.koushikdutta.superuser", + "com.thirdparty.superuser", + "com.yellowes.su", + "com.kingroot.kinguser", + "com.kingo.root", + "com.smedialink.oneclickroot", + "com.zhiqupk.root.global", + "com.alephzain.framaroot" + ) + + /** + * Exécute la vérification de présence des applications de root connues. + * + * Interroge le [PackageManager] pour chaque package de la liste. + * Une exception [PackageManager.NameNotFoundException] indique que le package + * n'est pas installé ou n'est pas visible (Android 11+). + * + * @return `true` si au moins une application de root est détectée, `false` sinon. + */ + override fun check(): Boolean = + knownRootPackages.any { packageName -> isPackageInstalled(packageName) } + + /** + * Vérifie si un package donné est installé et visible sur l'appareil. + * + * @param packageName Le nom complet du package à vérifier (ex: `com.topjohnwu.magisk`). + * @return `true` si le package est installé et visible, `false` sinon. + */ + private fun isPackageInstalled(packageName: String): Boolean = + runCatching { packageManager.getPackageInfo(packageName, 0) } + .isSuccess +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/check/ProcessCheck.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/ProcessCheck.kt new file mode 100644 index 0000000..4cc4003 --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/ProcessCheck.kt @@ -0,0 +1,40 @@ +package com.monext.sdk.internal.security.check + +import android.content.Context + +class ProcessCheck(private val context: Context) : SecurityCheck { + + private val suspiciousProcessNames = listOf( + "supersu", + "superuser", + "daemonsu", + "magisk", + "su_daemon" + ) + + override fun check(): Boolean = detectSuspiciousProcesses() + + private fun detectSuspiciousProcesses(): Boolean = runCatching { + // Lecture de /proc au lieu de getRunningServices (déprécié API 26+) + val procDir = java.io.File("/proc") + procDir.listFiles { file -> + file.isDirectory && file.name.all { it.isDigit() } + }?.any { pidDir -> + val cmdlineFile = java.io.File(pidDir, "cmdline") + if (cmdlineFile.exists()) { + val processName = cmdlineFile.readText() + .replace("\u0000", "") + .trim() + .lowercase() + isSuspiciousProcess(processName) + } else { + false + } + } ?: false + }.getOrDefault(false) + + private fun isSuspiciousProcess(processName: String): Boolean = + suspiciousProcessNames.any { indicator -> + processName.contains(indicator, ignoreCase = true) + } +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/check/SecurityCheck.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/SecurityCheck.kt new file mode 100644 index 0000000..14ec633 --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/check/SecurityCheck.kt @@ -0,0 +1,9 @@ +package com.monext.sdk.internal.security.check + +/** + * Interface fonctionnelle commune à toutes les vérifications de sécurité. + * ... + */ +fun interface SecurityCheck { + fun check(): Boolean +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/security/ui/SecurityAlertDialog.kt b/monext/src/main/kotlin/com/monext/sdk/internal/security/ui/SecurityAlertDialog.kt new file mode 100644 index 0000000..7849cdb --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/security/ui/SecurityAlertDialog.kt @@ -0,0 +1,25 @@ +package com.monext.sdk.internal.security.ui + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +internal fun SecurityAlertDialog(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = { /* non cancelable */ }, + title = { Text("Appareil non sécurisé") }, + text = { + Text( + "Votre appareil ne répond pas aux exigences de sécurité " + + "requises pour effectuer un paiement." + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Fermer") + } + } + ) +} \ No newline at end of file diff --git a/monext/src/test/kotlin/com/monext/sdk/PaymentBoxTest.kt b/monext/src/test/kotlin/com/monext/sdk/PaymentBoxTest.kt new file mode 100644 index 0000000..84d057b --- /dev/null +++ b/monext/src/test/kotlin/com/monext/sdk/PaymentBoxTest.kt @@ -0,0 +1,120 @@ +package com.monext.sdk + +import com.monext.sdk.internal.security.RootDetector +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import kotlin.test.Test + +class PaymentBoxTest { + + @BeforeEach + fun setup() { + mockkObject(RootDetector) + } + + @AfterEach + fun teardown() { + unmockkObject(RootDetector) + } + + @Test + fun onClick_whenSessionTokenIsNull_doesNothing() { + // Arrange + every { RootDetector.isCompromised() } returns false + var showPaymentSheet = false + var showSecurityAlert = false + + val onClick = buildOnClick( + sessionToken = null, + onShowPaymentSheet = { showPaymentSheet = true }, + onShowSecurityAlert = { showSecurityAlert = true } + ) + + // Act + onClick() + + // Assert + assertFalse(showPaymentSheet) + assertFalse(showSecurityAlert) + } + + @Test + fun onClick_whenTokenIsValidAndDeviceIsClean_showsPaymentSheet() { + // Arrange + every { RootDetector.isCompromised() } returns false + var showPaymentSheet = false + var showSecurityAlert = false + + val onClick = buildOnClick( + sessionToken = "valid_token", + onShowPaymentSheet = { showPaymentSheet = true }, + onShowSecurityAlert = { showSecurityAlert = true } + ) + + // Act + onClick() + + // Assert + assertTrue(showPaymentSheet) + assertFalse(showSecurityAlert) + } + + @Test + fun onClick_whenTokenIsValidAndDeviceIsCompromised_showsSecurityAlert() { + // Arrange + every { RootDetector.isCompromised() } returns true + var showPaymentSheet = false + var showSecurityAlert = false + + val onClick = buildOnClick( + sessionToken = "valid_token", + onShowPaymentSheet = { showPaymentSheet = true }, + onShowSecurityAlert = { showSecurityAlert = true } + ) + + // Act + onClick() + + // Assert + assertFalse(showPaymentSheet) + assertTrue(showSecurityAlert) + } + + @Test + fun onClick_whenDeviceIsCompromised_neverShowsPaymentSheet() { + // Arrange + every { RootDetector.isCompromised() } returns true + var showPaymentSheet = false + + val onClick = buildOnClick( + sessionToken = "valid_token", + onShowPaymentSheet = { showPaymentSheet = true }, + onShowSecurityAlert = {} + ) + + // Act + onClick() + + // Assert + assertFalse(showPaymentSheet) + } + + private fun buildOnClick( + sessionToken: String?, + onShowPaymentSheet: () -> Unit, + onShowSecurityAlert: () -> Unit + ): () -> Unit = { + if (sessionToken != null) { + if (RootDetector.isCompromised()) { + onShowSecurityAlert() + } else { + onShowPaymentSheet() + } + } + } +} \ No newline at end of file diff --git a/monext/src/test/kotlin/com/monext/sdk/internal/security/check/BuildCheckTest.kt b/monext/src/test/kotlin/com/monext/sdk/internal/security/check/BuildCheckTest.kt new file mode 100644 index 0000000..9517aaa --- /dev/null +++ b/monext/src/test/kotlin/com/monext/sdk/internal/security/check/BuildCheckTest.kt @@ -0,0 +1,183 @@ +package com.monext.sdk.internal.security.check + +import android.os.Build +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.lang.reflect.Field + +class BuildCheckTest { + + private val buildCheck = BuildCheck() + + private val originalValues = BUILD_FIELDS.associateWith { readBuildField(it) } + + @BeforeEach + fun setSafeDefaults() { + setBuildField("TAGS", "release-keys") + setBuildField("FINGERPRINT", "google/walleye/walleye:11/RP1A.201005.004/1234:user/release-keys") + setBuildField("PRODUCT", "walleye") + setBuildField("HARDWARE", "walleye") + setBuildField("MANUFACTURER", "Google") + setBuildField("MODEL", "Pixel 2") + setBuildField("BRAND", "google") + } + + @AfterEach + fun restoreOriginalBuildValues() { + originalValues.forEach { (name, value) -> setBuildField(name, value) } + } + + @Test + fun checkReturnsFalseOnLegitimateDevice() { + assertFalse(buildCheck.check()) + } + + // --- detectTestKeys --- + + @Test + fun checkReturnsTrueWhenTagsContainsTestKeys() { + setBuildField("TAGS", "test-keys") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsFalseWhenTagsIsNull() { + setBuildField("TAGS", null) + assertFalse(buildCheck.check()) + } + + // --- detectCustomRom --- + + @Test + fun checkReturnsTrueWhenFingerprintContainsCustom() { + setBuildField("FINGERPRINT", "lineageos/custom_rom/build:11/...:user/release-keys") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenFingerprintContainsTestKeys() { + setBuildField("FINGERPRINT", "generic/sdk/sdk:11/...:userdebug/test-keys") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenFingerprintContainsCustomUppercase() { + setBuildField("FINGERPRINT", "Some/CUSTOM/Build:11/...:user/release-keys") + assertTrue(buildCheck.check()) + } + + // --- detectEmulator --- + + @Test + fun checkReturnsTrueWhenProductContainsSdk() { + setBuildField("PRODUCT", "sdk_gphone_x86") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenProductContainsSimulator() { + setBuildField("PRODUCT", "android_simulator") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenProductContainsVbox86p() { + setBuildField("PRODUCT", "vbox86p") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenHardwareIsGoldfish() { + setBuildField("HARDWARE", "goldfish") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenHardwareIsRanchu() { + setBuildField("HARDWARE", "ranchu") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenManufacturerIsGenymotion() { + setBuildField("MANUFACTURER", "Genymotion") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenManufacturerIsGenymotionLowercase() { + setBuildField("MANUFACTURER", "genymotion") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenModelContainsEmulator() { + setBuildField("MODEL", "Android Emulator") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenModelContainsEmulatorLowercase() { + setBuildField("MODEL", "some emulator device") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenModelIsAndroidSdkBuiltForX86() { + setBuildField("MODEL", "Android SDK built for x86") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenBrandIsGeneric() { + setBuildField("BRAND", "generic") + assertTrue(buildCheck.check()) + } + + @Test + fun checkReturnsTrueWhenBrandIsGenericUppercase() { + setBuildField("BRAND", "GENERIC") + assertTrue(buildCheck.check()) + } + + companion object { + private val BUILD_FIELDS = listOf( + "TAGS", "FINGERPRINT", "PRODUCT", "HARDWARE", + "MANUFACTURER", "MODEL", "BRAND" + ) + + private val unsafeClass: Class<*> = Class.forName("sun.misc.Unsafe") + + private val unsafe: Any = run { + val theUnsafe = unsafeClass.getDeclaredField("theUnsafe") + theUnsafe.isAccessible = true + theUnsafe.get(null) + } + + private val staticFieldBase = + unsafeClass.getMethod("staticFieldBase", Field::class.java) + + private val staticFieldOffset = + unsafeClass.getMethod("staticFieldOffset", Field::class.java) + + private val putObject = unsafeClass.getMethod( + "putObject", + Any::class.java, + java.lang.Long.TYPE, + Any::class.java + ) + + private fun readBuildField(name: String): Any? = + Build::class.java.getField(name).get(null) + + private fun setBuildField(name: String, value: Any?) { + val field = Build::class.java.getField(name) + val base = staticFieldBase.invoke(unsafe, field) + val offset = staticFieldOffset.invoke(unsafe, field) as Long + putObject.invoke(unsafe, base, offset, value) + } + } +} \ No newline at end of file diff --git a/monext/src/test/kotlin/com/monext/sdk/internal/security/check/DebuggerCheckTest.kt b/monext/src/test/kotlin/com/monext/sdk/internal/security/check/DebuggerCheckTest.kt new file mode 100644 index 0000000..882ef6e --- /dev/null +++ b/monext/src/test/kotlin/com/monext/sdk/internal/security/check/DebuggerCheckTest.kt @@ -0,0 +1,94 @@ +package com.monext.sdk.internal.security.check + +import android.os.Debug +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class DebuggerCheckTest { + + private val debuggerCheck = DebuggerCheck() + + @BeforeEach + fun setup() { + mockkStatic(Debug::class) + every { Debug.isDebuggerConnected() } returns false + every { Debug.waitingForDebugger() } returns false + // timing par défaut : sous le seuil (10ms) + every { Debug.threadCpuTimeNanos() } returnsMany listOf(0L, 1_000_000L) + } + + @AfterEach + fun teardown() = unmockkAll() + + @Test + fun checkReturnsFalseWhenNoDebuggerAndFastTiming() { + assertFalse(debuggerCheck.check()) + } + + @Test + fun checkReturnsTrueWhenJavaDebuggerConnected() { + every { Debug.isDebuggerConnected() } returns true + assertTrue(debuggerCheck.check()) + } + + @Test + fun checkReturnsTrueWhenTimingExceedsThreshold() { + every { Debug.threadCpuTimeNanos() } returnsMany listOf(0L, 50_000_000L) + assertTrue(debuggerCheck.check()) + } + + @Test + fun detectJavaDebuggerReturnsFalseWhenNeitherFlagSet() { + assertFalse(debuggerCheck.detectJavaDebugger()) + } + + @Test + fun detectJavaDebuggerReturnsTrueWhenDebuggerConnected() { + every { Debug.isDebuggerConnected() } returns true + assertTrue(debuggerCheck.detectJavaDebugger()) + } + + @Test + fun detectJavaDebuggerReturnsTrueWhenWaitingForDebugger() { + every { Debug.waitingForDebugger() } returns true + assertTrue(debuggerCheck.detectJavaDebugger()) + } + + @Test + fun detectJavaDebuggerReturnsTrueWhenBothFlagsSet() { + every { Debug.isDebuggerConnected() } returns true + every { Debug.waitingForDebugger() } returns true + assertTrue(debuggerCheck.detectJavaDebugger()) + } + + @Test + fun detectDebuggerByTimingReturnsFalseWhenElapsedBelowThreshold() { + every { Debug.threadCpuTimeNanos() } returnsMany listOf(0L, 9_999_999L) + assertFalse(debuggerCheck.detectDebuggerByTiming()) + } + + @Test + fun detectDebuggerByTimingReturnsTrueWhenElapsedEqualsThreshold() { + every { Debug.threadCpuTimeNanos() } returnsMany listOf(0L, 10_000_000L) + assertTrue(debuggerCheck.detectDebuggerByTiming()) + } + + @Test + fun detectDebuggerByTimingReturnsTrueWhenElapsedFarAboveThreshold() { + every { Debug.threadCpuTimeNanos() } returnsMany listOf(0L, 500_000_000L) + assertTrue(debuggerCheck.detectDebuggerByTiming()) + } + + @Test + fun detectDebuggerByTimingHandlesNonZeroStartTime() { + // Start à 1 000 000, end à 11 000 000 → elapsed = 10 000 000 (= seuil) + every { Debug.threadCpuTimeNanos() } returnsMany listOf(1_000_000L, 11_000_000L) + assertTrue(debuggerCheck.detectDebuggerByTiming()) + } +} \ No newline at end of file diff --git a/monext/src/test/kotlin/com/monext/sdk/internal/security/check/RootDetectorTest.kt b/monext/src/test/kotlin/com/monext/sdk/internal/security/check/RootDetectorTest.kt new file mode 100644 index 0000000..c1de79a --- /dev/null +++ b/monext/src/test/kotlin/com/monext/sdk/internal/security/check/RootDetectorTest.kt @@ -0,0 +1,120 @@ +package com.monext.sdk.internal.security + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class RootDetectorTest { + + private val context: Context = mockk(relaxed = true) + + @BeforeEach + fun setup() { + every { context.applicationContext } returns context + mockkConstructor(RootDetectorImpl::class) + every { anyConstructed().isDeviceCompromised() } returns false + resetSingletonState() + } + + @AfterEach + fun teardown() { + resetSingletonState() + unmockkAll() + } + + @Test + fun isCompromisedReturnsFalseBeforeInit() { + assertFalse(RootDetector.isCompromised()) + } + + @Test + fun isCompromisedReturnsFalseOnCleanDevice() { + every { anyConstructed().isDeviceCompromised() } returns false + RootDetector.init(context) + assertFalse(RootDetector.isCompromised()) + } + + @Test + fun isCompromisedReturnsTrueOnRootedDevice() { + every { anyConstructed().isDeviceCompromised() } returns true + RootDetector.init(context) + assertTrue(RootDetector.isCompromised()) + } + + @Test + fun initIsIdempotent() { + RootDetector.init(context) + RootDetector.init(context) + RootDetector.init(context) + // 1 seul refreshCheck déclenché par init + verify(exactly = 1) { anyConstructed().isDeviceCompromised() } + } + + @Test + fun isCompromisedUsesCacheWithinTtl() { + RootDetector.init(context) // refresh #1 + repeat(5) { RootDetector.isCompromised() } + verify(exactly = 1) { anyConstructed().isDeviceCompromised() } + } + + @Test + fun isCompromisedRefreshesAfterTtlExpires() { + RootDetector.init(context) // refresh #1 + setLastCheckTime(System.currentTimeMillis() - 31_000L) + RootDetector.isCompromised() // refresh #2 + verify(exactly = 2) { anyConstructed().isDeviceCompromised() } + } + + @Test + fun isCompromisedReflectsLatestStateAfterTtlExpires() { + every { anyConstructed().isDeviceCompromised() } returns false + RootDetector.init(context) + assertFalse(RootDetector.isCompromised()) + + every { anyConstructed().isDeviceCompromised() } returns true + setLastCheckTime(System.currentTimeMillis() - 31_000L) + assertTrue(RootDetector.isCompromised()) + } + + @Test + fun forceRefreshAlwaysRecomputes() { + RootDetector.init(context) // refresh #1 + RootDetector.forceRefresh() // #2 + RootDetector.forceRefresh() // #3 + verify(exactly = 3) { anyConstructed().isDeviceCompromised() } + } + + @Test + fun forceRefreshReturnsUpdatedValue() { + every { anyConstructed().isDeviceCompromised() } returns false + RootDetector.init(context) + assertFalse(RootDetector.isCompromised()) + + every { anyConstructed().isDeviceCompromised() } returns true + assertTrue(RootDetector.forceRefresh()) + } + + private fun resetSingletonState() { + setField("initialized", false) + setField("compromised", false) + setField("lastCheckTime", 0L) + setField("detectorImpl", null) + } + + private fun setLastCheckTime(time: Long) = setField("lastCheckTime", time) + + private fun setField(name: String, value: Any?) { + RootDetector::class.java.getDeclaredField(name).apply { + isAccessible = true + set(RootDetector, value) + } + } +} \ No newline at end of file