Sécurité Agents¶
Mesures de sécurité conformes aux recommandations ANSSI pour l'application agents.
Vue d'ensemble¶
| Domaine | Mesure | Standard |
|---|---|---|
| Stockage local | SQLCipher | AES-256-GCM |
| Tokens | EncryptedSharedPreferences | AES-256-GCM + AES-256-SIV |
| Transport | TLS 1.3 | Certificate Pinning |
| Authentification | JWT | HS256, rotation auto |
| Logs | Conditionnel | Désactivés en production |
Chiffrement local (SQLCipher)¶
Configuration¶
companion object {
fun getDatabase(context: Context): AppDatabase {
System.loadLibrary("sqlcipher")
val passphrase = FingerprintManager
.generateFingerprint(context.applicationContext)
.toByteArray(StandardCharsets.UTF_8)
val factory = SupportOpenHelperFactory(passphrase)
return Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"urbafix_agents_database"
)
.openHelperFactory(factory)
.build()
}
}
Algorithme¶
| Paramètre | Valeur |
|---|---|
| Chiffrement | AES-256 en mode GCM |
| Dérivation de clé | PBKDF2-HMAC-SHA512 (256 000 itérations) |
| Page size | 16 Ko (compatible Android 15+) |
| HMAC | SHA-512 pour l'intégrité |
Données protégées¶
| Table | Données sensibles |
|---|---|
agents |
Email, nom, prénom |
incidents |
Adresse, coordonnées GPS, description |
photos_incident |
Photos Base64, coordonnées GPS |
comments_incident |
Contenu des commentaires |
Stockage des tokens¶
EncryptedSharedPreferences¶
class TokenManager(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val sharedPreferences = EncryptedSharedPreferences.create(
context,
"urbafix_agents_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
Schéma de chiffrement¶
┌─────────────────────────────────────────────────────┐
│ EncryptedSharedPreferences │
├─────────────────────────────────────────────────────┤
│ Clé (AES-256-SIV) │ Valeur (AES-256-GCM) │
├───────────────────────┼─────────────────────────────┤
│ [chiffré] │ access_token = [chiffré] │
│ [chiffré] │ refresh_token = [chiffré] │
│ [chiffré] │ expires_at = [chiffré] │
│ [chiffré] │ agent_id = [chiffré] │
│ [chiffré] │ collectivite_id = [chiffré]│
└───────────────────────┴─────────────────────────────┘
Master Key¶
- Stockée dans Android Keystore
- Protégée par le hardware (TEE/StrongBox si disponible)
- Non extractible
Transport sécurisé¶
Certificate Pinning (OkHttp)¶
private fun createOkHttpClient(): OkHttpClient {
val certificatePinner = CertificatePinner.Builder()
.add("urbafix.fr", "sha256/PU4bTrzvP9A7Ft6KDCL+E90q/7+CT/H9oB0iwTy6iS8=")
.build()
return OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.addInterceptor(JwtAuthInterceptor(tokenManager))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
Network Security Config¶
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">urbafix.fr</domain>
<pin-set expiration="2026-12-31">
<pin digest="SHA-256">PU4bTrzvP9A7Ft6KDCL+E90q/7+CT/H9oB0iwTy6iS8=</pin>
</pin-set>
</domain-config>
</network-security-config>
Renouvellement du pin¶
# 1. Extraire le nouveau pin
echo | openssl s_client -connect urbafix.fr:443 -servername urbafix.fr \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| base64
# 2. Mettre à jour RetrofitClient.kt et network_security_config.xml
# 3. Mettre à jour la date d'expiration
Authentification JWT¶
Secret obligatoire côté serveur¶
JWT_SECRET
La variable JWT_SECRET doit être définie dans .env. Le serveur refuse de démarrer sans elle — aucun fallback sur une valeur par défaut n'est accepté depuis le durcissement de juin 2026.
Un serveur sans JWT_SECRET retourne 500 Erreur de configuration serveur sur toute tentative de login.
Rotation des tokens¶
Intercepteur JWT¶
class JwtAuthInterceptor(
private val tokenManager: TokenManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
if (tokenManager.isTokenExpired()) {
val refreshed = refreshToken()
if (!refreshed) {
tokenManager.clearTokens()
throw TokenExpiredException()
}
}
val token = tokenManager.getAccessToken()
val authenticatedRequest = originalRequest.newBuilder()
.header("Authorization", "Bearer $token")
.build()
return chain.proceed(authenticatedRequest)
}
}
Logging conditionnel¶
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
| Mode | Niveau | Données loggées |
|---|---|---|
| Debug | BODY | Headers + Body complet |
| Release | NONE | Rien |
Production
En production, aucune donnée sensible n'est loggée :
- Pas de tokens JWT
- Pas de mots de passe
- Pas de photos Base64
- Pas de coordonnées GPS
- Pas de données personnelles
Conformité ANSSI¶
| Recommandation | Implémentation | Statut |
|---|---|---|
| Chiffrement stockage local | SQLCipher AES-256 | ✅ |
| Chiffrement tokens | EncryptedSharedPreferences | ✅ |
| Transport sécurisé | TLS 1.3 | ✅ |
| Certificate Pinning | OkHttp + Android config | ✅ |
| Authentification forte | JWT avec rotation | ✅ |
| JWT_SECRET obligatoire | Pas de fallback faible côté serveur | ✅ |
| CORS whitelist | SecurityHelpers::setCorsHeaders() |
✅ |
| Validation entrées | Client + Serveur | ✅ |
| Logs sécurisés | Désactivés en prod (Android) + error_log serveur uniquement | ✅ |
| Erreurs génériques | $e->getMessage() jamais exposé dans les réponses API |
✅ |
| Minification code | R8 enabled | ✅ |
Checklist pré-release¶
- Certificate pinning actif
- Logging désactivé
- Minification activée
-
allowBackup="false" -
debuggable="false" - Pas de secrets en clair
- Tokens avec expiration courte
- Validation entrées complète