MFormations
Modern Mobile Engineering

Chapitre 15

15 - Sécurité Mobile

15 - Sécurité Mobile

15 - Sécurité Mobile : Cours complet

Niveau : Intermédiaire → Avancé Durée estimée : 6 h Objectif : sécuriser une application mobile de bout en bout : stockage, transport, biométrie, code, signes, vie privée.


Table des matières

  1. OWASP Mobile Top 10
  2. Stockage sécurisé
  3. Biométrie
  4. TLS et certificate pinning
  5. Sécurité des deep links
  6. Anti-tampering
  7. ProGuard / R8
  8. Code signing
  9. Config sécurisée et dépendances
  10. Vie privée

1. OWASP Mobile Top 10

1.1 Les 10 risques (2023-2026)

#RisqueDescription
M1Mauvaises pratiques de credentialsMots de passe faibles, stockage de tokens
M2Stockage non sécurisé des donnéesDonnées sensibles en clair dans SQLite/SharedPrefs/logs
M3Communication non sécuriséeHTTP, TLS faible, certs non vérifiés
M4Mauvaise gestion de sessionTokens statiques, pas d'expiration
M5Code d'authentification faible2FA absent, biométrie mal intégrée
M6Reverse engineeringAPK décompilable, logique exposée
M7Injection (client)SQLi, XSS dans les WebViews, code injection
M8Décisions non fiables côté clientRègles de sécurité dans l'app au lieu du serveur
M9Gestion incorrecte de la plateformeMauvais usage des permissions, WebViews, intents
M10Attaques du réseau / MITMDNS poisoning, Wi-Fi rogue, interception TLS

1.2 La carte de la sécurité mobile

Diagramme en cours de génération...

1.3 Le principe fondamental

Le client mobile n'est jamais fiable. Toutes les règles de sécurité importantes doivent être ré-enforcées côté serveur (M8).


2. Stockage sécurisé

2.1 La hiérarchie de sécurité

DonnéeSolution
Secrets app (clés API, secrets de signature)Ne pas stocker dans l'app (déplacer vers le serveur) ou Keystore/Keychain
Tokens de sessionKeystore / Keychain
Mots de passeNe JAMAIS stocker (hash côté serveur) ; stocker que le token
Données personnellesChiffrées at-rest (SQLCipher, EncryptedSharedPreferences)
Données publiquesSQLite/DataStore normaux

2.2 Android Keystore

Le Keystore Android conserve des clés de chiffrement dans un conteneur matériel ou logiciel sécurisé. L'app ne manipule jamais la clé en clair : elle demande des opérations de chiffrement.

class KeystoreProvider {

    private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }

    fun getOrCreateKey(alias: String = "app_key"): SecretKey {
        keyStore.getKey(alias, null)?.let { return it as SecretKey }

        val generator = KeyGenerator.getInstance(
            KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
        )
        generator.init(
            KeyGenParameterSpec.Builder(
                alias,
                KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
            )
                .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                .setUserAuthenticationRequired(false) // option biométrie : true
                .build()
        )
        return generator.generateKey()
    }

    fun encrypt(data: ByteArray): ByteArray {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
        return cipher.iv + cipher.doFinal(data)
    }
}

2.3 EncryptedSharedPreferences

AndroidX fournit une version chiffrée de SharedPreferences (clés AES + HMAC) :

val prefs = EncryptedSharedPreferences.create(
    context,
    "secure_prefs",
    MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)

prefs.edit().putString("refresh_token", refreshToken).apply()

2.4 iOS Keychain

Le Keychain est l'équivalent iOS : stockage chiffré géré par le système (Secure Enclave pour les clés).

import Security

struct KeychainStore {

    static func save(key: String, value: String) throws {
        let data = Data(value.utf8)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        ]
        SecItemDelete(query as CFDictionary)          // nettoyer l'existant
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError(status: status) }
    }

    static func read(key: String) throws -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status != errSecItemNotFound else { return nil }
        guard status == errSecSuccess, let data = result as? Data else {
            throw KeychainError(status: status)
        }
        return String(data: data, encoding: .utf8)
    }
}

Points clés :

  • kSecAttrAccessibleWhenUnlockedThisDeviceOnly : pas de sync iCloud, accès seulement déverrouillé.
  • Ne pas mettre de secrets en UserDefaults.

2.5 Cross-platform

PlateformeLib
Flutterflutter_secure_storage (Keystore/Keychain sous le capot)
React Nativereact-native-keychain / expo-secure-store
KMPmultiplatform-settings + secure storage

3. Biométrie

3.1 Pourquoi la biométrie ?

  • UX (pas de mot de passe à retaper)
    • sécurité (l'authentification est liée au device)
  • La clé n'est utilisable que si la biométrie est vérifiée (Keystore setUserAuthenticationRequired(true))

3.2 Android — BiometricPrompt

class BiometricAuth(
    private val fragmentActivity: FragmentActivity,
    private val onSuccess: (Cipher) -> Unit,
    private val onError: (String) -> Unit
) {

    fun authenticate() {
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Déverrouiller TaskFlow")
            .setSubtitle("Utilisez votre empreinte ou Face")
            .setNegativeButtonText("Annuler")
            .build()

        val cipher = CryptoHelper.getCipherWithBiometrics() // clé Keystore biométrique
        val crypto = BiometricPrompt.CryptoObject(cipher)

        BiometricPrompt(fragmentActivity, object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                onSuccess(result.cryptoObject?.cipher ?: cipher)
            }

            override fun onAuthenticationFailed() { /* essai échoué */ }

            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                onError(errString.toString())
            }
        }).authenticate(promptInfo, crypto)
    }
}

La clé créée avec setUserAuthenticationRequired(true) (avec ou sans setInvalidatedByBiometricEnrollment) garantit que le déchiffrement n'est possible qu'après une vérification biométrique.

3.3 iOS — LocalAuthentication

import LocalAuthentication

final class BiometricAuth {
    private let context = LAContext()

    var canEvaluate: Bool {
        var error: NSError?
        return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
    }

    func authenticate() async throws -> Bool {
        let reason = "Déverrouillez TaskFlow pour continuer"
        return try await context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: reason
        )
    }
}

Face ID : il faut déclarer NSFaceIDUsageDescription dans Info.plist, sinon l'app crash au premier appel.

3.4 La biométrie et le chiffrement : le pattern crypto+biométrie

Le flux complet :

  1. Créer une clé Keystore/Keychain avec exigence biométrique.
  2. Chiffrer un secret (refresh token) avec cette clé.
  3. À l'usage : prompter la biométrie → le système déchiffre.
  4. Si l'utilisateur change d'empreinte/Face (enrollment), la clé est invalidée → demander une re-auth forte.

3.5 Les pièges

  • Fallback : toujours proposer le code PIN/schéma (policy .deviceOwnerAuthentication).
  • Ne jamais traiter la biométrie comme une authentification à 100 % serveur — c'est une authentification locale ; le serveur garde la main (M5).
  • Tester les cas d'erreur (5 échecs → fallback).

4. TLS et certificate pinning

4.1 TLS correct

  • HTTPS partout.
  • TLS 1.2 minimum, TLS 1.3 préféré.
  • Android : network_security_config.xml pour interdire le cleartext.
  • iOS : ATS (App Transport Security) actif par défaut.
<!-- Android network_security_config.xml -->
<network-security-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system"/>
        </trust-anchors>
    </base-config>
</network-security-config>
<!-- iOS Info.plist : ATS -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <false/>
</dict>

4.2 Certificate pinning (rappel chapitre 12)

Épingler le hash de la clé publique :

val pinner = CertificatePinner.Builder()
    .add("api.taskflow.app", "sha256/AAAA", "sha256/BBBB") // + fallback
    .build()

Importants :

  • Pinner en production uniquement (dev/staging avec des configs séparées).
  • Prévoir la rotation : double pin + délai de grâce.
  • En cas de pin mal configuré, toute la base utilisateurs perd l'accès — attention !

5. Sécurité des deep links

5.1 Le risque

Un deep link mal configuré peut :

  • rediriger l'utilisateur vers des pages de phishing,
  • déclencher des actions sans consentement,
  • être intercepté par une autre app malveillante.

5.2 Android — Verified App Links

<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data
        android:scheme="https"
        android:host="taskflow.app"
        android:pathPrefix="/tickets/" />
</intent-filter>

Ajouter le fichier assetlinks.json sur le domaine et déclarer android:autoVerify="true" : Android ne laisse que la vraie app gérer le domaine.

5.3 iOS — Universal Links

  • Configurer les Associated Domains (applinks:taskflow.app).
  • Héberger le fichier apple-app-site-association sur le domaine.
  • Dans onContinueUserActivity : vérifier la URL et le URLComponents (jamais utiliser la URL brute sans validation).
func application(_ app: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL,
          url.host == "taskflow.app" else { return false }

    // analyser le path ET valider les paramètres
    return Router.handle(url)
}

5.4 Les règles deep links

  1. Toujours valider la source (host, path) et les paramètres.
  2. Ne jamais repasser par JavaScript/WebView sans contrôle.
  3. Pour les actions sensibles (paiement, suppression) : exiger une ré-authentification.
  4. Utiliser les schemes custom (taskflow://) avec parcimonie (moins sûrs que https/universal links).

6. Anti-tampering

6.1 Qu'est-ce qu'un appareil compromis ?

  • Root / jailbreak : l'attaquant a les droits admin sur le device.
  • App modifiée : re-signée, code injecté.
  • Debugger attaché : inspection en temps réel.

6.2 Détections courantes (légitimes en contexte de sécurité)

// Android — détection root (version simplifiée)
fun isRooted(): Boolean {
    val paths = arrayOf(
        "/system/app/Superuser.apk",
        "/sbin/su",
        "/system/bin/su",
        "/system/xbin/su"
    )
    return paths.any { File(it).exists() } || runCatching {
        Runtime.getRuntime().exec("su").apply { destroy() } // tentative d'exécution
    }.isSuccess
}
// iOS — détection jailbreak (version simplifiée)
func isJailbroken() -> Bool {
    let paths = [
        "/Applications/Cydia.app",
        "/usr/sbin/sshd",
        "/bin/bash",
    ]
    return paths.contains { FileManager.default.fileExists(atPath: $0) }
}

6.3 Les limites (à assumer)

La détection de root/jailbreak est bypassable (cat-and-mouse). Vaut mieux :

  1. Refuser/limiter certaines actions sensibles sur device compromis (transaction haute valeur).
  2. Défendre en profondeur : chiffrement, serveur, monitoring (fraude).
  3. Ne pas pénaliser les utilisateurs simplement « rootés » de bonne foi (paiement, etc.).

7. ProGuard / R8

7.1 Le rôle

  • Minification : supprimer les classes/membres inutilisés → APK plus petit.
  • Obfuscation : renommer les classes/champs en a, b… → reverse engineering plus dur.
  • Optimisation : inlining, élimination de code mort.

7.2 Configuration Android

// build.gradle.kts (module)
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}
# proguard-rules.pro
# Conserver les modèles sérialisés (kotlinx / Moshi)
-keep class com.taskflow.app.domain.model.** { *; }
# Conserver les annotations Retrofit (interfaces API)
-keep interface com.taskflow.app.data.remote.** { *; }
# Règles des libs (Room, Retrofit...) via les consumer rules

7.3 Les pièges

  • Oublier les règles pour les libs utilisant la réflexion (Retrofit, Room, kotlinx.serialization).
  • Tester la release build (comportements différents de debug).
  • La sauvegarde des stack traces : mapper le stacktrace dé-obfusqué (retrace) dans Crashlytics.

7.4 iOS — le cas particulier

iOS n'a pas d'équivalent R8 pour l'App Store (le code est compilé en natif). La strip des symboles (STRIP_INSTALLED_PRODUCT) et le Bitcode (abandonné) limitaient la rétro-ingénierie. La protection iOS repose surtout sur : compilation native, code signing, et l'effort OWASP M6 reste plus faible que sur Android.


8. Code signing

8.1 Android — APK/AAB signing

  • Générer un keystore (fichier .jks + alias + passwords) → à protéger absolument (gitignored, secret manager en CI).
  • app signing by Google Play (Play Console) : la clé d'app est conservée par Google, vous gardez la clé de upload.
  • Ne jamais perdre la clé d'upload : sans elle, pas de mise à jour.
// build.gradle.kts
android {
    signingConfigs {
        create("release") {
            storeFile = file("keystore/taskflow.jks")
            storePassword = System.getenv("KEYSTORE_PASSWORD")
            keyAlias = "taskflow"
            keyPassword = System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release { signingConfig = signingConfigs.getByName("release") }
    }
}

8.2 iOS — Certificats et provisioning

  • Certificat de distribution (signe le binaire) — géré par Developer Account.
  • Provisioning profile (lie app + device/capabilities).
  • Xcode 14+ : le certificat peut être géré automatiquement (Signing & Capabilities).

8.3 Fastlane match (voir chapitre 16)

match stocke les certificats/profiles chiffrés dans un repo et les synchronise entre machines — le standard des équipes.


9. Config sécurisée et dépendances

9.1 Les secrets ne vont PAS dans l'app

Mauvaise pratiqueAlternative
apiKey = "abc123" en clair dans le codeClé serveur (l'app appelle votre API qui cache ses secrets)
Clé API Firebase dans le buildFirebase config protégée + règles de sécurité côté serveur
Secrets dans local.propertiesDéjà gitignored — mais pas livrés non plus !

La règle : aucun secret qui vaille la peine ne doit être embarqué dans un APK/IPA (facilement décompilable).

9.2 Les builds de configuration

// Android BuildConfig (minifié en release)
buildConfigField("String", "API_URL", "\"https://api.taskflow.app\"")

Utiliser des environnements séparés (dev/staging/prod) avec des URLs/configs différentes, jamais les secrets de prod en debug.

9.3 Scanner les dépendances

OutilRôle
Dependabot (GitHub)Alertes de vulnérabilités
RenovateMises à jour automatiques des deps
osv-scannerScan des vulnérabilités (OWASP + Google)
Snyk / TrivyScan applicatif
# Exemple : osv-scanner
osv-scanner scan -r .

9.4 Gradle — plugin de sécurité (bonus)

  • dependency-check-gradle (OWASP) pour le scan en CI.
  • Toujours garder Gradle/AGP/NDK à jour.

10. Vie privée

10.1 GDPR et le mobile

  • Minimisation : ne collecter que le nécessaire.
  • Consentement : obtenir avant de collecter (notamment pour les analytiques).
  • Droit à l'effacement : prévoir la suppression des données utilisateur.
  • Data Protection Impact Assessment pour les données sensibles.

10.2 Permissions Android

  • Principle of least privilege : ne demander que ce qui est nécessaire.
  • Runtime permissions : demander au moment de l'usage, avec explication.
  • Fichier android:maxSdkVersion pour limiter les permissions obsolètes.

10.3 iOS Privacy

  • App Privacy labels (App Store) : déclarer les données collectées.
  • App Tracking Transparency (ATT) : demander l'autorisation de tracking via ATTrackingManager.requestTrackingAuthorization().
  • Photo/Contact/Microphone : requêtes runtime avec NS*UsageDescription.

10.4 La checklist vie privée

  • Consentement explicite avant la collecte
  • Données chiffrées at-rest et en transit
  • Minimisation des données
  • Droit d'effacement implémenté
  • Labels de confidentialité à jour (iOS)
  • Permissions demandées au bon moment
  • Analytics anonymisés ou pseudonymisés
  • Pas de tracking sans consentement (ATT / EU)

11. Synthèse

Diagramme en cours de génération...

Points à retenir :

  1. Le client n'est jamais fiable → sécurité serveur.
  2. Les secrets sensibles n'existent pas dans l'app ; les tokens vont dans Keystore/Keychain.
  3. TLS + pinning pour le transport.
  4. Deep links validés et ré-authentification pour les actions sensibles.
  5. R8 activé en release, stack traces dé-obfusquées.
  6. Code signing maîtrisé et protégé.
  7. GDPR : minimisation, consentement, effacement.

Aller plus loin

  • Chapitre 16 (DevOps) : signing dans la CI, match, scan de deps.
  • Chapitre 18 (Projet-Fil-Rouge) : la sécurité complète de TaskFlow.