Aller au contenu

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.

# Générer un secret fort (≥ 32 octets)
openssl rand -hex 32
# .env
JWT_SECRET=<valeur générée ci-dessus>

Un serveur sans JWT_SECRET retourne 500 Erreur de configuration serveur sur toute tentative de login.

Rotation des tokens

Séquence de rotation des tokens JWT : login génère un access token (1h) et un refresh token (7j) ; après expiration, l'app échange le refresh token contre une nouvelle paire via /auth/refresh

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