// SPDX-License-Identifier: Proprietary // SPKI (Subject Public Key Info) certificate pinning service for UnionFlow mobile. // // Remplace l'ancien check CN par un check de digest SHA-256 de la SPKI, conforme // aux recommandations 2026 : // - Google déconseille le pinning de cert statique (rotation légitime brique l'app) // - Let's Encrypt 90 jours / certs commerciaux 398 jours max → cert statique = brick garanti // - SPKI digest survit aux rotations tant que la clé publique reste la même // // Pour rotation effective : la liste de pins est récupérée dynamiquement depuis Firebase // Remote Config, ce qui permet d'ajouter de nouvelles clés (transition) sans re-publier l'app. // // @since 2026-04-25 (P0-NEW-21) import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:firebase_remote_config/firebase_remote_config.dart'; import '../utils/app_logger.dart'; /// Liste de digests SHA-256 (en base64) des SPKI valides. /// /// Mise à jour dynamique via Firebase Remote Config (clé `spki_pins`) avec rotation /// transparente. Format : tableau de strings base64. class SpkiPinningService { SpkiPinningService._(); static final SpkiPinningService instance = SpkiPinningService._(); /// Digests SHA-256 (base64) à shipper en fallback statique dans le bundle — /// utilisés si Firebase Remote Config est indisponible au démarrage. /// /// **Avant go-live**, remplir avec : /// 1. Le digest de la clé publique du cert prod (api.lions.dev) /// 2. Le digest d'une clé de backup (à provisionner avant rotation) /// 3. Optionnellement : le digest d'une clé Let's Encrypt intermédiaire static const List _fallbackPins = [ // TODO avant go-live : // 'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // prod cert pubkey // 'sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', // backup pubkey ]; /// Hosts soumis au pinning (regex acceptée). static const List _pinnedHosts = [ 'api.lions.dev', 'security.lions.dev', ]; /// Cache des pins après chargement Firebase (refresh à chaque démarrage app). List? _activePins; /// Initialise le service en récupérant la liste de pins depuis Firebase Remote Config. /// Doit être appelé au démarrage de l'app, avant toute requête HTTPS. Future initialize() async { try { final remoteConfig = FirebaseRemoteConfig.instance; await remoteConfig.setConfigSettings(RemoteConfigSettings( fetchTimeout: const Duration(seconds: 10), // Min interval = 0 en debug, 1h en prod (best practice Firebase) minimumFetchInterval: kReleaseMode ? const Duration(hours: 1) : Duration.zero, )); await remoteConfig.setDefaults({ 'spki_pins': jsonEncode(_fallbackPins), }); await remoteConfig.fetchAndActivate(); final pinsJson = remoteConfig.getString('spki_pins'); _activePins = (jsonDecode(pinsJson) as List).cast(); AppLogger.info( 'SPKI pinning initialisé (${_activePins!.length} pins actifs)', tag: 'SpkiPinning'); } catch (e) { AppLogger.warning( 'SPKI pinning : échec Firebase Remote Config, fallback bundle ($_fallbackPins.length pins) — $e', tag: 'SpkiPinning'); _activePins = _fallbackPins; } } /// Vérifie qu'un certificat X.509 a une SPKI dont le digest SHA-256 figure dans la liste /// active de pins. Retourne true si OK, false si le pin ne matche pas. /// /// Utilisé dans `HttpClient.badCertificateCallback` : /// ```dart /// client.badCertificateCallback = (cert, host, port) => /// SpkiPinningService.instance.verifyCertificate(cert, host); /// ``` bool verifyCertificate(X509Certificate cert, String host) { if (!_isPinnedHost(host)) { // Hôte non soumis au pinning → comportement par défaut (validation système) // Note : badCertificateCallback n'est appelé QUE si la validation système a déjà échoué. // Donc retourner false pour respecter le rejet par défaut. AppLogger.warning( 'SSL: cert refusé pour $host (host non-pinné, validation système a échoué)', tag: 'SpkiPinning'); return false; } if (_activePins == null || _activePins!.isEmpty) { AppLogger.error( 'SPKI pinning : aucune liste de pins active — refus par défaut', tag: 'SpkiPinning'); return false; } final spkiDigest = _computeSpkiSha256Base64(cert); if (spkiDigest == null) { AppLogger.error( 'SPKI pinning : impossible de calculer le digest pour $host', tag: 'SpkiPinning'); return false; } final formatted = 'sha256/$spkiDigest'; final matched = _activePins!.contains(formatted); if (!matched) { AppLogger.error( 'SPKI pinning REJECT : $host digest=$formatted ne match aucun pin actif', tag: 'SpkiPinning'); } return matched; } bool _isPinnedHost(String host) { final lower = host.toLowerCase(); for (final pinned in _pinnedHosts) { if (lower == pinned || lower.endsWith('.$pinned')) { return true; } } return false; } /// Calcule SHA-256 de la SubjectPublicKeyInfo du certificat, encodé en base64. /// /// Note : `X509Certificate.der` retourne la DER complète du cert. Pour extraire la SPKI /// proprement il faudrait parser ASN.1 — pas trivial en Dart standard. Solution /// pragmatique : hasher l'intégralité de la DER (variant courant "Certificate Pinning" /// plutôt que pure SPKI). Pour le vrai SPKI, intégrer le package `asn1lib` ou /// `app_fortress` qui le calcule nativement. /// /// **TODO avant go-live banking-grade** : passer à `app_fortress` pour SPKI réel + /// hardening natif (Frida bypass). String? _computeSpkiSha256Base64(X509Certificate cert) { try { final der = cert.der; final digest = sha256.convert(der); return base64Encode(digest.bytes); } catch (e) { AppLogger.error('SPKI digest computation failed: $e', tag: 'SpkiPinning'); return null; } } }