Authentification et sécurité
OAuth 2.0 et OpenID Connect côté mobile, PKCE, jetons, stockage sécurisé, biométrie, puis l'OWASP MASVS comme grille pour ranger pinning, chiffrement, détection de root et obfuscation. Dans une banque, un télécom ou une administration, ce chapitre décide souvent de l'entretien : le RSSI ou l'architecte sécurité est dans la salle.
Objectifs
- Dérouler au tableau le flux Authorization Code + PKCE, et dire pourquoi implicit et ROPC sont abandonnés.
- Distinguer access token, refresh token et ID token, et savoir ce que le client a le droit de faire d'un JWT.
- Configurer flutter_secure_storage et local_auth avec leurs API actuelles, et expliquer ce que la biométrie protège réellement.
- Présenter l'OWASP MASVS v2 et y ranger chaque mesure concrète, avec ses limites.
- Tenir une conversation crédible avec un RSSI : menaces, mesures, risques résiduels.
OAuth 2.0 et OpenID Connect côté mobile
OAuth 2.0 est un protocole d'autorisation : il permet à une application d'obtenir un jeton pour appeler une API au nom d'un utilisateur. OpenID Connect (OIDC) ajoute la couche d'authentification : un ID token qui dit qui est l'utilisateur, un endpoint userinfo et un document de découverte (/.well-known/openid-configuration). Dans les grands comptes, le fournisseur est souvent Keycloak (ou Red Hat build of Keycloak), parfois Azure AD / Entra ID, Ping, ForgeRock ou un produit local.
| Rôle OAuth | Dans notre contexte |
|---|---|
| Resource owner | L'utilisateur, titulaire du compte |
| Client | L'app Flutter : un client public, incapable de garder un secret |
| Authorization server | Keycloak (un realm, par exemple wallet) |
| Resource server | Les microservices Spring Boot, derrière l'API gateway, qui valident le jeton |
Le point de départ du raisonnement : une app mobile est un client public. Tout ce qui est dans le binaire peut être extrait. Il n'y a donc pas de client_secret dans l'app, et la sécurité du flux repose sur PKCE et sur le navigateur système.
Authorization Code + PKCE, pas à pas
- code_verifier : chaîne aléatoire cryptographiquement sûre, 43 à 128 caractères (RFC 7636). Elle ne quitte l'app qu'à l'étape 6, sur un canal TLS direct avec le serveur.
- code_challenge :
BASE64URL(SHA-256(code_verifier))sans padding, méthodeS256. La méthodeplainn'apporte presque rien et doit être refusée par le serveur. - redirect URI : un scheme privé (
ma.exemple.wallet:/oauthredirect, nom de domaine inversé) ou mieux, un lien vérifié (App Links Android, Universal Links iOS). Le serveur n'accepte que les URI enregistrées. - state protège contre le CSRF ; nonce lie l'ID token à cette requête.
- À l'échange, le serveur recalcule le hash du verifier et le compare au challenge reçu à l'étape 2.
Ce que PKCE résout : sur mobile, une autre app malveillante peut déclarer le même scheme et intercepter le code au retour. Sans le code_verifier, ce code est inutilisable. PKCE remplace le secret client qu'un client public ne peut pas avoir.
({String verifier, String challenge}) createPkcePair() {
final random = Random.secure();
final bytes = List<int>.generate(32, (_) => random.nextInt(256));
// 43 caractères base64url sans padding (RFC 7636 : 43 à 128).
final verifier = base64Url.encode(bytes).replaceAll('=', '');
final digest = sha256.convert(ascii.encode(verifier)).bytes;
final challenge = base64Url.encode(digest).replaceAll('=', '');
return (verifier: verifier, challenge: challenge);
}
Ce qui est déconseillé, et pourquoi
- Implicit flow (
response_type=token) : le jeton revient directement dans l'URL de redirection, exposé à l'historique, aux logs et aux apps qui interceptent le scheme, sans refresh token ni preuve de possession. Abandonné par le OAuth 2.0 Security Best Current Practice (RFC 9700, janvier 2025) et absent d'OAuth 2.1. - ROPC (Resource Owner Password Credentials,
grant_type=password) : l'app collecte le mot de passe et l'envoie au serveur. Elle voit donc le secret de l'utilisateur, rend impossible la MFA gérée par le fournisseur, et habitue les utilisateurs à taper leur mot de passe SSO dans n'importe quelle app. Interdit par la même BCP. - WebView embarquée pour la page de login : l'app hôte peut lire les champs saisis et les cookies, l'utilisateur ne voit pas la barre d'adresse, et il n'y a pas de session SSO partagée. La RFC 8252 (OAuth 2.0 for Native Apps) impose un user-agent externe : navigateur système,
ASWebAuthenticationSessionsur iOS, Custom Tabs sur Android. Certains fournisseurs (Google) bloquent même les WebViews.
« Notre contrat d'API a un POST /auth/login avec téléphone et mot de passe, c'est donc du ROPC ? » Pas au sens OAuth : c'est une authentification propriétaire de première partie, où l'app et l'API appartiennent au même acteur. C'est courant dans les wallets. Le risque est le même (l'app manipule le mot de passe), d'où les règles : jamais stocké, jamais loggué, TLS + pinning, et migration vers OIDC + PKCE quand un SSO d'entreprise existe. Savoir faire cette nuance montre que vous ne récitez pas.
Access token, refresh token, ID token et JWT
| Jeton | Destinataire | Durée typique | Usage côté app |
|---|---|---|---|
| Access token | L'API (resource server) | Minutes (300 s dans le projet) | En-tête Authorization, gardé en mémoire |
| Refresh token | L'authorization server | Heures à jours, à rotation | Obtenir une nouvelle paire ; stockage sécurisé |
| ID token | Le client | Courte | Identité de l'utilisateur, id_token_hint du logout |
Un JWT est header.payload.signature, chaque partie encodée en base64url. Le payload contient des claims : iss (émetteur), sub (sujet), aud (audience), exp, iat, plus des claims métier (rôles Keycloak dans realm_access). L'encodage n'est pas un chiffrement : n'importe qui peut lire un JWT.
/// Lit les claims d'un JWT SANS vérifier la signature : affichage seulement
/// (nom, exp pour anticiper un refresh). Le serveur reste seul juge.
Map<String, dynamic> readJwtClaims(String jwt) {
final parts = jwt.split('.');
if (parts.length != 3) throw const FormatException('JWT invalide');
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
return jsonDecode(payload) as Map<String, dynamic>;
}
- Ce que le client peut faire : lire
exppour planifier un refresh, lire le nom pour l'afficher, lire des rôles pour adapter l'UI (masquer un bouton). - Ce qu'il ne doit pas faire : prendre une décision de sécurité sur la base de ces claims. Masquer un bouton n'est pas une autorisation ; c'est le microservice qui vérifie signature,
iss,aud,expet rôles. Un attaquant peut modifier l'app pour afficher le bouton. - L'access token peut d'ailleurs être opaque (pas un JWT) selon le fournisseur : le client ne doit pas dépendre de son format.
- L'ID token, lui, doit être validé (signature,
iss,aud,nonce) avant usage. AppAuth vérifienonceet une partie des claims ; dans une architecture à back-end, on délègue la confiance au serveur.
Expiration et rotation
Access token court, refresh token à rotation : à chaque refresh, le serveur émet un nouveau refresh token et invalide l'ancien. Si un refresh token volé est rejoué après usage légitime, le serveur détecte la réutilisation et peut révoquer toute la session. Dans Keycloak, c'est l'option de realm Revoke Refresh Token. Conséquence côté client : un seul refresh à la fois (chapitre 09), sinon vous déclenchez vous-même la détection de vol. Pour aller plus loin, DPoP (RFC 9449) lie le jeton à une clé détenue par l'appareil ; à connaître de nom.
flutter_appauth et Keycloak
flutter_appauth enveloppe les SDK AppAuth d'OpenID Foundation (AppAuth-iOS, AppAuth-Android). Il gère PKCE, state, nonce, la découverte et le user-agent externe. La version 12 exige Flutter 3.38.1, Android 7.0 (API 24) et iOS 13, et adopte le cycle de vie UIScene. Par défaut, iOS utilise ASWebAuthenticationSession ; Android, Custom Tabs.
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
const _issuer = 'https://sso.exemple.ma/realms/wallet'; // realm Keycloak
const _clientId = 'wallet-mobile'; // client public : aucun secret
const _redirectUrl = 'ma.exemple.wallet:/oauthredirect';
final _appAuth = FlutterAppAuth();
/// Authorization Code + PKCE : AppAuth génère code_verifier et code_challenge
/// (S256), ouvre le navigateur système, vérifie state et échange le code.
Future<AuthorizationTokenResponse> login() => _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
_clientId,
_redirectUrl,
issuer: _issuer, // découverte via /.well-known/openid-configuration
scopes: const ['openid', 'profile', 'offline_access'],
),
);
Future<TokenResponse> refresh(String refreshToken) => _appAuth.token(
TokenRequest(
_clientId,
_redirectUrl,
issuer: _issuer,
refreshToken: refreshToken,
),
);
/// Déconnexion côté fournisseur (RP-initiated logout) : ferme la session SSO.
Future<void> logout(String idToken) => _appAuth.endSession(
EndSessionRequest(
idTokenHint: idToken,
postLogoutRedirectUrl: _redirectUrl,
issuer: _issuer,
),
);
/// Ce que fait AppAuth en interne, pour pouvoir l'expliquer.
({String verifier, String challenge}) createPkcePair() {
final random = Random.secure();
final bytes = List<int>.generate(32, (_) => random.nextInt(256));
// 43 caractères base64url sans padding (RFC 7636 : 43 à 128).
final verifier = base64Url.encode(bytes).replaceAll('=', '');
final digest = sha256.convert(ascii.encode(verifier)).bytes;
final challenge = base64Url.encode(digest).replaceAll('=', '');
return (verifier: verifier, challenge: challenge);
}
/// Lit les claims d'un JWT SANS vérifier la signature : affichage seulement
/// (nom, exp pour anticiper un refresh). Le serveur reste seul juge.
Map<String, dynamic> readJwtClaims(String jwt) {
final parts = jwt.split('.');
if (parts.length != 3) throw const FormatException('JWT invalide');
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
return jsonDecode(payload) as Map<String, dynamic>;
}
android {
defaultConfig {
// scheme de redirection capté par l'activité AppAuth
manifestPlaceholders["appAuthRedirectScheme"] = "ma.exemple.wallet"
}
}
Côté Keycloak, ce que vous devez savoir demander à l'équipe IAM, sans être expert serveur :
- Un client public (Client authentication désactivé), flux Standard flow seul, Direct access grants (ROPC) désactivé, Implicit flow désactivé.
- PKCE Method =
S256imposé dans les paramètres avancés du client. - Valid redirect URIs et Valid post logout redirect URIs exacts, sans joker.
- Scope
offline_accessseulement si l'on veut un refresh token longue durée (ce qui a des implications de révocation). - Durées : access token de quelques minutes, SSO Session Idle et Max alignés sur la politique de la banque, rotation des refresh tokens.
- L'URL de découverte :
https://sso.exemple.ma/realms/wallet/.well-known/openid-configuration.
Logout et end_session
Une déconnexion complète comporte trois étapes : révoquer le refresh token côté serveur (POST /auth/logout dans le projet, ou l'endpoint de révocation du fournisseur), fermer la session SSO dans le navigateur (RP-initiated logout via end_session_endpoint avec id_token_hint), puis effacer jetons, cache et données locales. Oublier la deuxième étape donne un bug classique : l'utilisateur se « déconnecte », appuie sur « Se connecter », et rentre sans mot de passe parce que le cookie SSO du navigateur est toujours valide. Avec ASWebAuthenticationSession, l'option prefersEphemeralWebBrowserSession évite de partager les cookies, au prix du SSO entre apps.
« Côté mobile, j'utilise Authorization Code avec PKCE S256 via flutter_appauth, dans le navigateur système conformément à la RFC 8252, jamais dans une WebView. L'app est un client public : aucun secret client dans le binaire. L'access token reste en mémoire, le refresh token va dans le Keystore ou le Keychain, et la rotation impose un seul refresh à la fois. Pour le logout, je révoque le refresh token, j'appelle end_session pour fermer la session SSO, puis j'efface les données locales. »
Stockage sécurisé
flutter_secure_storage s'appuie sur le Keychain iOS et, sur Android, sur une clé du Keystore (matérielle quand l'appareil le permet) qui chiffre les valeurs stockées dans des SharedPreferences. La version 11 (août 2026) a supprimé les options dépréciées en v10 : plus d'encryptedSharedPreferences (Jetpack Security est abandonné), plus de sharedPreferencesName (remplacé par storageNamespace), plus d'AES-CBC. Le défaut est AES-GCM pour les données, clé enveloppée en RSA-OAEP par le Keystore ; minSdk 24.
Migration : passer directement d'une version antérieure à la v10 vers la v11 rend illisibles les données chiffrées avec les anciens algorithmes. Il faut passer par la v10, qui migre, puis par la v11 (qui fournit checkUpgradeStatus() pour détecter une perte). Sur une app bancaire en production, une mise à jour de dépendance qui déconnecte tous les utilisateurs est un incident.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../net/token_store.dart';
class SecureTokenStore implements TokenStore {
SecureTokenStore([FlutterSecureStorage? storage])
: _storage = storage ??
const FlutterSecureStorage(
// Défaut v10+ : AES-GCM, clé enveloppée par le Keystore (RSA OAEP).
aOptions: AndroidOptions(storageNamespace: 'wallet_auth'),
iOptions: IOSOptions(
// Lisible après le premier déverrouillage (refresh en arrière-plan),
// jamais restauré sur un autre appareil ni synchronisé iCloud.
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
final FlutterSecureStorage _storage;
static const _refreshKey = 'refresh_token';
String? _accessToken; // en mémoire seulement : durée de vie de 5 min
@override
String? get accessToken => _accessToken;
@override
Future<String?> readRefreshToken() => _storage.read(key: _refreshKey);
@override
Future<void> save(TokenPair pair) async {
_accessToken = pair.accessToken;
await _storage.write(key: _refreshKey, value: pair.refreshToken);
}
@override
Future<void> clear() async {
_accessToken = null;
await _storage.delete(key: _refreshKey);
}
}
/// Secret lié à la biométrie : le système exige l'empreinte ou le visage
/// pour déchiffrer, ce n'est pas un simple écran par-dessus l'app.
const biometricBoundStorage = FlutterSecureStorage(
aOptions: AndroidOptions.biometric(
enforceBiometrics: true,
biometricPromptTitle: 'Déverrouiller Wallet',
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.passcode,
accessControlFlags: [AccessControlFlag.biometryCurrentSet],
),
);
- Accessibilité iOS :
first_unlock_this_devicepermet un refresh en arrière-plan après le premier déverrouillage, et le suffixe_this_deviceempêche la restauration sur un autre appareil et la synchronisation iCloud.unlocked_this_deviceest plus strict mais casse les tâches de fond. - Sauvegarde Android : les valeurs chiffrées restaurées sur un nouvel appareil sont illisibles (la clé Keystore ne voyage pas). Excluez ces fichiers de l'Auto Backup (
dataExtractionRules, ouandroid:allowBackup="false"), comme le recommande le README du package. - Keychain iOS et désinstallation : les entrées du Keychain peuvent survivre à une désinstallation. Au premier lancement (drapeau absent dans les préférences), effacez le stockage sécurisé, sinon un nouvel utilisateur hérite d'une session.
Ce qu'on ne stocke jamais
- Le mot de passe de l'utilisateur, même chiffré. On stocke un refresh token révocable, jamais le secret qui permet d'en obtenir.
- Un
client_secret, une clé d'API privée, une clé de signature serveur. - Le PIN de transaction ou l'OTP.
- Des données sensibles dans
shared_preferences, dans des fichiers en clair, dans les logs ou dans le cache HTTP.
Biométrie avec local_auth
La biométrie ne transmet rien au serveur : elle dit à l'app que le système a reconnu une empreinte ou un visage enregistré sur l'appareil. C'est donc un déverrouillage local, pas une authentification serveur. Les usages corrects sont de déverrouiller l'app sur un appareil déjà connecté, ou de libérer un secret stocké localement.
local_auth 3.0 a changé l'API : AuthenticationOptions est remplacé par des paramètres directs (biometricOnly, persistAcrossBackgrounding, ancien stickyAuth, sensitiveTransaction), et les erreurs sont des LocalAuthException avec un LocalAuthExceptionCode au lieu de PlatformException. Sur Android, l'activité doit hériter de FlutterFragmentActivity ; sur iOS, NSFaceIDUsageDescription est obligatoire dans Info.plist.
import 'package:local_auth/local_auth.dart';
enum UnlockResult { unlocked, cancelled, unavailable, lockedOut }
class BiometricLock {
BiometricLock([LocalAuthentication? auth]) : _auth = auth ?? LocalAuthentication();
final LocalAuthentication _auth;
Future<bool> isAvailable() async =>
await _auth.isDeviceSupported() && await _auth.canCheckBiometrics;
/// Verrou local : il protège l'accès à l'app sur un appareil déjà connecté.
/// Il ne prouve rien au serveur.
Future<UnlockResult> unlock() async {
try {
final ok = await _auth.authenticate(
localizedReason: 'Déverrouillez Wallet pour continuer',
biometricOnly: false, // repli sur le code de l'appareil
persistAcrossBackgrounding: true,
);
return ok ? UnlockResult.unlocked : UnlockResult.cancelled;
} on LocalAuthException catch (e) {
return switch (e.code) {
LocalAuthExceptionCode.temporaryLockout ||
LocalAuthExceptionCode.biometricLockout => UnlockResult.lockedOut,
LocalAuthExceptionCode.noBiometricHardware ||
LocalAuthExceptionCode.noBiometricsEnrolled ||
LocalAuthExceptionCode.noCredentialsSet => UnlockResult.unavailable,
_ => UnlockResult.cancelled,
};
}
}
}
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'biometric_lock.dart';
final biometricLockProvider = Provider<BiometricLock>((ref) => BiometricLock());
/// État du verrou applicatif : verrouillé au démarrage et après 2 min en fond.
class AppLock extends Notifier<bool> {
DateTime? _pausedAt;
@override
bool build() => true; // verrouillé
Future<void> unlock() async {
final result = await ref.read(biometricLockProvider).unlock();
if (result == UnlockResult.unlocked) state = false;
}
void onPaused() => _pausedAt = DateTime.now();
void onResumed() {
final pausedAt = _pausedAt;
if (pausedAt != null && DateTime.now().difference(pausedAt) > const Duration(minutes: 2)) {
state = true;
}
}
}
final appLockProvider = NotifierProvider<AppLock, bool>(AppLock.new);
Deux niveaux de biométrie
Niveau 1, la porte d'UI : local_auth renvoie true, l'app affiche l'écran. Sur un appareil rooté avec Frida, un attaquant peut forcer ce true. Acceptable pour un verrou de confort, pas pour protéger un secret.
Niveau 2, la cryptographie liée à la biométrie : le secret (refresh token, clé de base de données) est chiffré par une clé du Keystore ou du Secure Enclave qui n'est utilisable qu'après une authentification biométrique réussie, vérifiée par le matériel. Forcer un booléen ne suffit plus : sans biométrie, pas de déchiffrement. flutter_secure_storage l'expose via AndroidOptions.biometric(enforceBiometrics: true) et accessControlFlags: [AccessControlFlag.biometryCurrentSet] sur iOS (voir biometricBoundStorage plus haut). biometryCurrentSet invalide le secret si une nouvelle empreinte est enrôlée, ce qui bloque l'attaque « j'ajoute mon doigt sur le téléphone de la victime ». Comportement à tester sur vos appareils cibles : les options biométriques de ce package ont beaucoup évolué entre les versions 10 et 11.
La biométrie prouve la présence d'un utilisateur enrôlé sur cet appareil, pas son identité au serveur. Pour une opération sensible (virement au-delà d'un seuil, ajout de bénéficiaire), MASVS-AUTH-3 attend une authentification supplémentaire vérifiée côté serveur : OTP, signature par une clé d'appareil enrôlée, ou ré-authentification OIDC avec prompt=login / max_age.
OWASP MASVS v2 et MASTG
Le MASVS (Mobile Application Security Verification Standard) est le référentiel d'exigences de sécurité mobile de l'OWASP ; la version actuelle est la 2.1.0. Le MASTG (Mobile Application Security Testing Guide) décrit comment tester chaque exigence, et le MASWE (Mobile Application Security Weakness Enumeration) catalogue les faiblesses. La v2 a remplacé les anciens niveaux L1 / L2 / R par des MAS profiles, eux-mêmes retravaillés avec le MASWE : vérifiez la page Profiles du site avant de citer un niveau précis. Les banques et les auditeurs (pentest annuel, homologation) s'y réfèrent : parler MASVS, c'est parler leur langue.
| Groupe | Contrôles | Mesures concrètes dans une app Flutter |
|---|---|---|
| MASVS-STORAGE | 1 : stocker les données sensibles de façon sûre. 2 : empêcher leur fuite. | flutter_secure_storage, base chiffrée, pas de logs sensibles, exclusion des sauvegardes, presse-papiers, cache |
| MASVS-CRYPTO | 1 : cryptographie forte et actuelle. 2 : gestion des clés. | AES-GCM, nonce unique, clés générées sur l'appareil et protégées par Keystore / Keychain, aucune clé en dur |
| MASVS-AUTH | 1 : protocoles d'authentification sûrs. 2 : authentification locale sûre. 3 : authentification supplémentaire pour les opérations sensibles. | OIDC + PKCE, rotation des jetons, biométrie liée à une clé, OTP ou signature pour les virements |
| MASVS-NETWORK | 1 : sécuriser tout le trafic. 2 : pinning d'identité des endpoints maîtrisés. | TLS partout, pas de cleartext, pinning SPKI avec pin de secours |
| MASVS-PLATFORM | 1 : IPC sûrs. 2 : WebViews sûres. 3 : UI sûre. | Deep links validés, pas de JavaScript inutile en WebView, FLAG_SECURE, masquage dans le multitâche |
| MASVS-CODE | 1 : plateforme à jour. 2 : mise à jour forçable. 3 : composants sans vulnérabilité connue. 4 : validation des entrées. | minSdk / iOS minimum, écran de mise à jour obligatoire, audit des dépendances, validation des deep links et des données serveur |
| MASVS-RESILIENCE | 1 : intégrité de la plateforme. 2 : anti-falsification. 3 : anti-analyse statique. 4 : anti-analyse dynamique. | Détection root / jailbreak, Play Integrity / App Attest, obfuscation, détection de débogueur et de hooking |
| MASVS-PRIVACY | 1 : minimiser l'accès aux données. 2 : empêcher l'identification. 3 : transparence. 4 : contrôle par l'utilisateur. | Permissions minimales, privacy manifests Apple, Data safety Google Play, suppression de compte |
« J'utilise le MASVS v2 comme grille : pour chaque groupe, je liste ce que l'app fait et le risque résiduel. Par exemple, MASVS-NETWORK-2 : pinning SPKI avec deux pins et une procédure de rotation ; MASVS-STORAGE-1 : refresh token dans le Keystore, base SQLite chiffrée ; MASVS-RESILIENCE : détection de root qui remonte un signal au back-end plutôt que de bloquer aveuglément. Je prépare les preuves pour le pentest à partir des cas de test du MASTG. »
Pinning de certificat (MASVS-NETWORK-2)
TLS valide la chaîne jusqu'à une autorité de confiance du système. Le pinning ajoute une contrainte : seule une identité attendue est acceptée. Cela bloque un proxy d'interception avec une autorité installée par l'utilisateur ou par un MDM, et une autorité compromise ou mal émise.
- Pin de certificat (hash du certificat entier) : change à chaque renouvellement, donc tous les 90 jours avec Let's Encrypt.
- Pin de clé publique SPKI (hash de SubjectPublicKeyInfo) : survit au renouvellement si la clé est conservée. C'est la forme recommandée, et le format des pins OkHttp et de
network_security_config. - Ancrage sur une AC (autorité de certification) : on ne fait confiance qu'à l'AC interne de la banque. Plus robuste aux renouvellements, moins strict.
Le risque de brick
Si le serveur change de clé et que l'app n'a que l'ancien pin, toutes les versions installées perdent le réseau : on « brique » l'app, et seule une mise à jour depuis le store répare, à condition que l'utilisateur la fasse. D'où les règles :
- Toujours au moins deux pins : la clé actuelle et une clé de secours générée à l'avance, conservée hors ligne par l'équipe infra.
- Une procédure de rotation écrite avec l'équipe qui gère les certificats de la gateway : publier une version de l'app avec le nouveau pin des semaines avant de changer de clé.
- Pinner les endpoints que l'on maîtrise, pas les SDK tiers.
- Un plan de secours : mise à jour forcée, voire liste de pins récupérable via un canal déjà pinné.
Implémentation avec Dio
Point important pour Flutter : dart:io embarque sa propre pile TLS (BoringSSL). Le <pin-set> du network_security_config.xml Android s'applique à la pile réseau de la plateforme (OkHttp, Cronet, SDK natifs), pas, à notre connaissance, au HttpClient de Dart. Le pinning des appels Dio se fait donc en Dart. X509Certificate de dart:io donne le DER du certificat feuille, pas la clé publique : il faut extraire le SPKI soi-même. Le code ci-dessous le fait en parcourant la structure DER, et produit le même pin que la commande openssl indiquée (vérifié sur des clés RSA et EC).
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
/// Pins SPKI (base64 du SHA-256 de la clé publique), au format HPKP/OkHttp.
/// Toujours au moins deux pins : la clé actuelle et une clé de secours.
const _pins = {
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // clé actuelle (exemple)
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', // clé de secours (exemple)
};
void enablePinning(Dio dio) {
dio.httpClientAdapter = IOHttpClientAdapter(
// Pas de createHttpClient : la validation de chaîne et de nom d'hôte
// par le magasin système reste active. Le pin s'ajoute, il ne remplace pas.
// validateCertificate est appelé à chaque réponse avec le certificat feuille.
validateCertificate: (cert, host, port) {
if (cert == null) return false;
return _pins.contains(spkiPin(cert.der));
},
);
}
/// base64(SHA-256(SubjectPublicKeyInfo)), équivalent de :
/// openssl x509 -pubkey -noout | openssl pkey -pubin -outform der
/// | openssl dgst -sha256 -binary | base64
String spkiPin(Uint8List certDer) =>
base64.encode(sha256.convert(_extractSpki(certDer)).bytes);
/// Parcours DER minimal : Certificate > TBSCertificate > 7e champ (SPKI).
Uint8List _extractSpki(Uint8List der) {
final cert = _Der(der, 0, der.length).enter(); // SEQUENCE Certificate
final tbs = cert.enter(); // SEQUENCE TBSCertificate
if (tbs.peekTag == 0xA0) tbs.skip(); // [0] version (optionnelle)
for (var i = 0; i < 5; i++) {
tbs.skip(); // serialNumber, signature, issuer, validity, subject
}
return tbs.readTlv(); // SubjectPublicKeyInfo, en-tête compris
}
class _Der {
_Der(this._b, this._pos, this._end);
final Uint8List _b;
int _pos;
final int _end;
int get peekTag => _b[_pos];
/// Retourne (début du contenu, longueur du contenu) et avance après l'en-tête.
(int, int) _header() {
if (_pos + 2 > _end) throw const FormatException('DER tronqué');
_pos++; // tag
var len = _b[_pos++];
if (len & 0x80 != 0) {
final n = len & 0x7F;
len = 0;
for (var i = 0; i < n; i++) {
len = (len << 8) | _b[_pos++];
}
}
return (_pos, len);
}
_Der enter() {
final (start, len) = _header();
_pos = start + len;
return _Der(_b, start, start + len);
}
void skip() {
final (start, len) = _header();
_pos = start + len;
}
Uint8List readTlv() {
final begin = _pos;
skip();
return Uint8List.sublistView(_b, begin, _pos);
}
}
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/services.dart';
/// Variante « ancrage sur l'AC » : on ne fait confiance qu'à l'autorité
/// interne de la banque, pas au magasin système.
Future<void> trustOnlyBankCa(Dio dio) async {
final caPem = await rootBundle.load('assets/certs/bank_root_ca.pem');
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final context = SecurityContext(withTrustedRoots: false)
..setTrustedCertificatesBytes(caPem.buffer.asUint8List());
return HttpClient(context: context);
},
);
}
<network-security-config>
<!-- Interdit le HTTP en clair pour la pile réseau Android -->
<base-config cleartextTrafficPermitted="false" />
<domain-config>
<domain includeSubdomains="true">api.exemple.ma</domain>
<!-- Pins SPKI pour les composants natifs (OkHttp, SDK tiers, WebView non concernée) -->
<pin-set expiration="2027-06-30">
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>
L'attribut expiration désactive les pins après une date : c'est un garde-fou contre le brick, au prix d'une protection qui s'éteint si l'app n'est pas mise à jour. Des packages encapsulent le pinning natif (par exemple via OkHttp et URLSession) ; ils obligent alors à passer par leur client HTTP au lieu de Dio.
« Le pinning empêche-t-il un attaquant de voir le trafic de l'app ? » Pas sur son propre appareil : avec un téléphone rooté et Frida, il désactive la vérification en mémoire (des scripts publics existent pour Flutter). Le pinning protège l'utilisateur contre un tiers sur le réseau, pas l'API contre un utilisateur malveillant. La sécurité de l'API se fait côté serveur.
Chiffrement au repos (MASVS-CRYPTO)
Le système chiffre déjà le stockage de l'appareil, mais ce chiffrement disparaît dès que le téléphone est déverrouillé, ou rooté. Le chiffrement applicatif protège les données sensibles sur un appareil compromis ou une sauvegarde extraite. Le choix d'algorithme est simple : AES-256-GCM, chiffrement authentifié qui détecte toute altération. La difficulté est ailleurs : la gestion des clés.
import 'dart:convert';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Chiffrement applicatif AES-256-GCM d'un blob (export, pièce jointe...).
/// La clé est générée sur l'appareil et rangée dans le Keystore / Keychain
/// via flutter_secure_storage : jamais en dur dans le code.
class FieldCipher {
FieldCipher(this._storage);
final FlutterSecureStorage _storage;
final _algo = AesGcm.with256bits();
static const _keyName = 'blob_key_v1';
Future<SecretKey> _key() async {
final stored = await _storage.read(key: _keyName);
if (stored != null) return SecretKey(base64.decode(stored));
final key = await _algo.newSecretKey();
await _storage.write(key: _keyName, value: base64.encode(await key.extractBytes()));
return key;
}
Future<Uint8List> encrypt(List<int> clear) async {
// Nonce de 12 octets aléatoire à CHAQUE chiffrement : jamais réutilisé.
final box = await _algo.encrypt(clear, secretKey: await _key());
return box.concatenation(); // nonce + chiffré + tag (16 octets)
}
Future<List<int>> decrypt(Uint8List data) async {
final box = SecretBox.fromConcatenation(data, nonceLength: 12, macLength: 16);
// Lève SecretBoxAuthenticationError si les données ont été altérées.
return _algo.decrypt(box, secretKey: await _key());
}
}
- Nonce unique : réutiliser un nonce avec la même clé en GCM casse la confidentialité et permet de forger des messages. Nonce aléatoire de 96 bits à chaque chiffrement.
- Pas de clé en dur, ni dans le code, ni dans un asset, ni dans un
--dart-define. La clé est générée sur l'appareil et protégée par le Keystore / Keychain. - Pas de crypto maison : ni ECB, ni CBC sans MAC, ni XOR. Utiliser une bibliothèque reconnue (
cryptography,pointycastle) ou les primitives natives. - Pour une base de données entière, préférer SQLCipher ou SQLite3MultipleCiphers (chapitre 11) plutôt que de chiffrer champ par champ.
Root, jailbreak, intégrité (MASVS-RESILIENCE)
Un appareil rooté ou jailbreaké casse le modèle de sécurité : le bac à sable n'isole plus l'app, le Keystore peut être interrogé, le code peut être instrumenté. La détection cherche des indices : binaire su, Magisk, Cydia, partition système modifiée, bootloader déverrouillé, Frida, débogueur, app re-signée.
Limites : c'est un jeu du chat et de la souris. Magisk masque le root, Frida peut neutraliser la fonction de détection. Une détection locale ne fait que relever le coût de l'attaque. Les bonnes pratiques : réaction graduée (journaliser, informer le back-end, restreindre les opérations sensibles, rarement bloquer), décision côté serveur, et combinaison avec une attestation.
freeRASP (Talsec) est un exemple répandu : détection de root, hooking, débogueur, re-signature, magasin non officiel, avec des callbacks. La version gratuite envoie des données de télémétrie à l'éditeur, point à valider avec le RSSI et le DPO.
import 'package:freerasp/freerasp.dart';
Future<void> startRasp({required void Function(String) report}) async {
final config = TalsecConfig(
watcherMail: 'securite-mobile@exemple.ma',
isProd: true,
androidConfig: AndroidConfig(
packageName: 'ma.exemple.wallet',
signingCertHashes: ['AKoRuyLMM91E7lX/Zqp3u4jMmd0A7hH/Iqozu0TMVd0='],
),
iosConfig: IOSConfig(bundleIds: ['ma.exemple.wallet'], teamId: 'ABCDE12345'),
);
// Réagir de façon graduée : journaliser, informer le back-end, restreindre.
await Talsec.instance.attachListener(
ThreatCallback(
onPrivilegedAccess: () => report('root_or_jailbreak'),
onHooks: () => report('hooking'), // Frida et consorts
onAppIntegrity: () => report('repackaged'),
onDebug: () => report('debugger'),
),
);
await Talsec.instance.start(config);
}
Attestation côté serveur : Play Integrity API (Android) et App Attest (iOS, framework DeviceCheck) produisent un jeton signé par Google ou Apple qui atteste que l'app est authentique et l'appareil intègre. Le jeton est vérifié par le back-end, pas par l'app : c'est beaucoup plus difficile à contourner qu'une détection locale. À intégrer via un platform channel ou un package communautaire, et à coordonner avec l'équipe back-end.
Écran, presse-papiers, logs (MASVS-PLATFORM-3, STORAGE-2)
Android : FLAG_SECURE sur la fenêtre bloque captures, enregistrement d'écran, affichage sur un écran non sécurisé, et masque l'aperçu dans le multitâche. Il n'y a pas d'API Flutter dédiée : quelques lignes dans MainActivity, ou un package (freeRASP expose blockScreenCapture). Android 14 ajoute un callback de détection de capture d'écran.
class MainActivity : FlutterFragmentActivity() { // requis par local_auth
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
iOS : pas d'API publique pour interdire une capture. On peut détecter après coup (userDidTakeScreenshotNotification), détecter un enregistrement ou une recopie d'écran (UIScreen.isCaptured, remplacé par l'état de capture de la scène sur les versions récentes : vérifiez l'API selon votre cible), et masquer le contenu quand l'app passe inactive pour que l'aperçu du multitâche soit neutre.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Champ mot de passe / OTP : ni suggestions, ni apprentissage du clavier.
class SecretField extends StatelessWidget {
const SecretField({super.key, required this.controller, required this.label});
final TextEditingController controller;
final String label;
@override
Widget build(BuildContext context) => TextField(
controller: controller,
obscureText: true,
autocorrect: false,
enableSuggestions: false,
enableIMEPersonalizedLearning: false, // clavier Android en mode incognito
keyboardType: TextInputType.visiblePassword,
autofillHints: const [AutofillHints.password], // gestionnaire de mots de passe OK
decoration: InputDecoration(labelText: label),
);
}
/// Masque le contenu dans le sélecteur d'applications (aperçu du multitâche).
class PrivacyCurtain extends StatefulWidget {
const PrivacyCurtain({super.key, required this.child});
final Widget child;
@override
State<PrivacyCurtain> createState() => _PrivacyCurtainState();
}
class _PrivacyCurtainState extends State<PrivacyCurtain> {
late final AppLifecycleListener _listener;
bool _hidden = false;
@override
void initState() {
super.initState();
_listener = AppLifecycleListener(
onInactive: () => setState(() => _hidden = true),
onResume: () => setState(() => _hidden = false),
);
}
@override
void dispose() {
_listener.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Stack(
children: [
widget.child,
if (_hidden) const Positioned.fill(child: ColoredBox(color: Colors.white)),
],
);
}
/// Copie d'un RIB : on efface le presse-papiers après un délai.
Future<void> copyTemporarily(String value) async {
await Clipboard.setData(ClipboardData(text: value));
Future<void>.delayed(const Duration(seconds: 60), () async {
final current = await Clipboard.getData(Clipboard.kTextPlain);
if (current?.text == value) await Clipboard.setData(const ClipboardData(text: ''));
});
}
Le rideau se déclenche aussi pendant l'invite Face ID sur iOS, qui rend l'app inactive : c'est acceptable, et même souhaitable.
- Presse-papiers : il est lisible par d'autres apps (avec notification depuis iOS 14 et Android 12). Ne jamais y mettre un mot de passe ou un OTP ; pour un RIB copié, effacer après un délai.
- Clavier :
enableSuggestions: false,autocorrect: false,enableIMEPersonalizedLearning: falsesur les champs sensibles, sinon le clavier apprend et suggère vos données. - Logs :
printetdebugPrintfinissent dans logcat en release. Un logger qui ne fait rien hors debug, et une règle de revue de code.
Obfuscation et secrets dans l'app
flutter build appbundle --release --obfuscate --split-debug-info=build/symbols/android
flutter build ipa --release --obfuscate --split-debug-info=build/symbols/ios
# Conserver build/symbols par version : indispensable pour lire les crashs
flutter symbolize -i stacktrace.txt -d build/symbols/android/app.android-arm64.symbols
--obfuscate renomme les identifiants Dart (classes, méthodes) dans le snapshot AOT. Ce qu'elle ne protège pas : les chaînes de caractères (URL, messages, clés en dur), les assets, la logique elle-même, les appels de plateforme. Des outils publics analysent les binaires Flutter AOT et retrouvent beaucoup d'informations. L'obfuscation relève le coût de l'analyse (MASVS-RESILIENCE-3) ; ce n'est pas une protection de secret.
« Où mettez-vous la clé d'API ? » La réponse attendue : aucun secret n'est sûr dans un binaire distribué. --dart-define et --dart-define-from-file injectent des constantes compilées, extractibles avec strings ou un désassembleur ; ce n'est pas un coffre. Une clé de service tierce (cartographie, paiement) doit être restreinte côté fournisseur (package, empreinte de signature, quotas) ou passer par un proxy back-end qui détient le vrai secret. --dart-define est fait pour la configuration : URL d'environnement, drapeaux.
Vie privée et stores (MASVS-PRIVACY)
- Privacy manifests Apple (
PrivacyInfo.xcprivacy), exigés depuis mai 2024 : données collectées, domaines de tracking, et raisons d'usage des required reason APIs (par exempleUserDefaults, utilisé parshared_preferences). Les plugins fournissent les leurs ; l'app déclare sa propre collecte. - Google Play : formulaire Data safety cohérent avec le comportement réel ; suppression de compte accessible depuis l'app et le web.
- Permissions minimales et demandées au moment de l'usage ; pas d'identifiant publicitaire dans une app bancaire sans raison.
- Loi 09-08 au Maroc (CNDP), RGPD pour les données de résidents européens : minimisation, durée de conservation, information. Le DPO valide ; le développeur doit savoir ce que l'app collecte réellement, SDK tiers compris.
Parler sécurité avec un RSSI de banque
Un RSSI ne cherche pas un expert en cryptographie. Il cherche quelqu'un qui raisonne en menaces et en risques résiduels, qui connaît les référentiels et qui ne sur-promet pas.
- Partir de la menace : « Contre qui protège-t-on ? Un voleur de téléphone, un attaquant réseau, un utilisateur fraudeur sur son propre appareil, une app malveillante installée à côté ? » Chaque mesure répond à une menace.
- Défense en profondeur : aucune mesure n'est absolue ; on les empile et la décision finale est serveur (limites de virement, scoring de fraude, attestation).
- Dire les limites : « La détection de root est contournable ; nous l'utilisons comme signal, pas comme barrière. » C'est ce qui inspire confiance.
- Référentiels : MASVS / MASTG pour le mobile, ASVS pour le back-end, exigences de Bank Al-Maghrib ou de la DGSSI côté Maroc, PCI DSS si des données de carte transitent. Vous n'avez pas à les connaître par cœur : savoir qu'ils existent et à quoi ils servent suffit.
- Exploitation : procédure de rotation des pins, gestion des symboles d'obfuscation, mise à jour forcée, réponse à incident, pentests avant chaque version majeure.
« Je commence par le modèle de menace avec l'équipe sécurité, puis je déroule le MASVS groupe par groupe. Pour chaque mesure, je dis ce qu'elle protège et ce qu'elle ne protège pas : le pinning protège l'utilisateur sur un réseau hostile, pas l'API contre un appareil compromis ; c'est pour cela que nous ajoutons Play Integrity et App Attest vérifiés côté serveur. Et je prévois l'exploitation : rotation des pins, symboles conservés, mise à jour forcée. »
Questions d'entretien
Expliquez PKCE et le problème qu'il résout.
L'app génère un code_verifier aléatoire, envoie son hash SHA-256 (code_challenge, méthode S256) à l'autorisation, puis le verifier en clair à l'échange du code. Une app tierce qui intercepte le code au retour (même scheme) ne peut pas l'échanger sans le verifier. PKCE remplace le secret qu'un client public ne peut pas garder.
Pourquoi ne pas faire le login OIDC dans une WebView ?
L'app hôte peut lire les saisies et les cookies, l'utilisateur ne voit pas l'URL, il n'y a pas de SSO partagé, et certains fournisseurs bloquent les WebViews. La RFC 8252 impose un user-agent externe : navigateur système, ASWebAuthenticationSession, Custom Tabs.
Où stockez-vous l'access token et le refresh token ?
L'access token en mémoire : il dure quelques minutes et on le récupère par refresh au redémarrage. Le refresh token dans flutter_secure_storage (Keychain avec accessibilité _this_device, Keystore Android), exclu des sauvegardes. Jamais dans shared_preferences, jamais le mot de passe.
Le client peut-il valider un JWT ?
Il peut le lire (claims en base64url) pour l'UI et anticiper l'expiration. Il ne doit pas fonder de décision de sécurité dessus : c'est le resource server qui vérifie signature, émetteur, audience, expiration et rôles. L'access token peut même être opaque.
La biométrie suffit-elle pour authentifier un virement ?
Non. La biométrie prouve qu'un utilisateur enrôlé est présent sur cet appareil, rien au serveur. Elle peut déverrouiller une clé liée au matériel qui signe une requête, et cette signature, elle, se vérifie côté serveur. Sinon, on ajoute un OTP ou une ré-authentification (MASVS-AUTH-3).
Comment faites-vous du certificate pinning sans briquer l'app ?
Pinning SPKI (clé publique), au moins deux pins dont une clé de secours générée à l'avance, procédure de rotation avec l'équipe infra (nouvelle version de l'app publiée avant le changement de clé), mise à jour forcée en secours. En Flutter, le pinning de Dio se fait en Dart via validateCertificate, car dart:io n'utilise pas la configuration réseau Android.
Que protège l'obfuscation Flutter ?
Elle renomme les symboles Dart et complique la rétro-ingénierie. Elle ne cache ni les chaînes, ni les assets, ni la logique. Il faut conserver les fichiers --split-debug-info de chaque version pour symboliser les crashs.
Comment protégez-vous une clé d'API tierce dans l'app ?
On ne peut pas la protéger : tout ce qui est dans le binaire est extractible, --dart-define compris. On restreint la clé côté fournisseur (identifiant d'app, empreinte de signature, quotas) ou on passe par un proxy back-end qui détient le secret.
Que faites-vous si l'appareil est rooté ?
Une réaction graduée, décidée avec le métier : journaliser, informer le back-end, restreindre les opérations sensibles, afficher un avertissement ; bloquer seulement si la politique l'impose. La détection locale est contournable : elle se complète par Play Integrity / App Attest vérifiés côté serveur.
Qu'est-ce que l'OWASP MASVS et comment l'utilisez-vous ?
Le standard OWASP des exigences de sécurité mobile, en huit groupes (STORAGE, CRYPTO, AUTH, NETWORK, PLATFORM, CODE, RESILIENCE, PRIVACY). Le MASTG décrit les tests correspondants. Je m'en sers comme grille de conception et de revue, et pour préparer les pentests.
Un utilisateur se déconnecte puis se reconnecte sans saisir de mot de passe. Pourquoi ?
La session SSO du navigateur est toujours active : l'app a effacé ses jetons sans appeler l'end_session_endpoint. Il faut un RP-initiated logout avec id_token_hint, plus la révocation du refresh token.
Exercices
Au tableau, dessinez le flux Authorization Code + PKCE avec les quatre acteurs et tous les paramètres (code_verifier, code_challenge, state, nonce, redirect_uri). Critère : l'expliquer en trois minutes, et répondre à « que se passe-t-il si une autre app intercepte le code ? ».
Écrivez createPkcePair() et readJwtClaims() de mémoire, avec un test qui vérifie la longueur du verifier (43 caractères pour 32 octets), l'absence de padding et le décodage d'un payload connu.
Sur une page : la fiche MASVS de l'app Wallet. Pour chacun des huit groupes, une mesure en place, la menace couverte et le risque résiduel. Critère : un collègue doit pouvoir la présenter à un RSSI sans vous.
Dans l'app Wallet (voir le projet) :
- Login : écran téléphone + mot de passe vers
POST /auth/login; champ mot de passe sans suggestions ;INVALID_CREDENTIALSmappé en message clair ; mot de passe jamais conservé après l'appel. - Stockage :
SecureTokenStorebranché sur l'intercepteur du chapitre 09 ; access token en mémoire, refresh token dans flutter_secure_storage ; effacement au premier lancement après réinstallation. - Déconnexion :
POST /auth/logoutavec le refresh token, puis effacement des jetons et des caches ; redirection go_router vers la connexion, y compris quand l'intercepteur signaleREFRESH_INVALID. - Verrouillage biométrique :
AppLockverrouillé au démarrage et après deux minutes en arrière-plan, déverrouillage parlocal_authavec repli sur le code de l'appareil ; rideau de confidentialité dans le multitâche ;FLAG_SECUREsur Android. - Pinning :
enablePinningactivé dans le flavor prod uniquement, deux pins, test qui vérifiespkiPincontre la valeur calculée paropensslsur un certificat de test. - Bonus PKCE : lancer un Keycloak local (image Docker officielle), créer un realm
walletet un client public avec PKCE S256, et brancherflutter_appauthdans un flavorsso: login, refresh, logout avecend_session.
Quiz
12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.
Pour aller plus loin
- OWASP MASVS et OWASP MASTG
- RFC 8252 : OAuth 2.0 for Native Apps
- RFC 7636 : PKCE
- RFC 9700 : OAuth 2.0 Security Best Current Practice
- flutter_appauth
- flutter_secure_storage (changelog v10 et v11)
- local_auth
- Obfuscation du code Dart (docs Flutter)
- Network security configuration (Android)
- Play Integrity API
- App Attest (Apple)
- Privacy manifest files (Apple)
- freeRASP