From d6f9e2c63b5cea11cc7edf87f8f9d272633d466f Mon Sep 17 00:00:00 2001 From: Vatsal Gandhi Date: Tue, 16 Dec 2025 22:09:53 +0530 Subject: [PATCH] feat(ssl): implement ssl pinning client adapter Signed-off-by: Vatsal Gandhi --- lib/fa_flutter_api_client.dart | 1 + lib/src/api_service_impl.dart | 22 ++- lib/src/ssl_pinning/ssl_pinning_config.dart | 32 ++++ .../ssl_pinning_http_client_adapter.dart | 137 ++++++++++++++++++ pubspec.lock | 32 ++++ pubspec.yaml | 1 + 6 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 lib/src/ssl_pinning/ssl_pinning_config.dart create mode 100644 lib/src/ssl_pinning/ssl_pinning_http_client_adapter.dart diff --git a/lib/fa_flutter_api_client.dart b/lib/fa_flutter_api_client.dart index 98c4a48..dcbfa3c 100644 --- a/lib/fa_flutter_api_client.dart +++ b/lib/fa_flutter_api_client.dart @@ -11,3 +11,4 @@ export 'src/api_options/api_options.dart'; export 'src/interceptors/cache_interceptor.dart'; export 'src/interceptors/cancel_token_interceptor.dart'; export 'src/interceptors/refresh_token_interceptor.dart'; +export 'src/ssl_pinning/ssl_pinning_config.dart'; diff --git a/lib/src/api_service_impl.dart b/lib/src/api_service_impl.dart index af0a8e0..d801d66 100644 --- a/lib/src/api_service_impl.dart +++ b/lib/src/api_service_impl.dart @@ -1,9 +1,9 @@ -import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:fa_flutter_api_client/fa_flutter_api_client.dart'; import 'package:fa_flutter_api_client/src/implementations/refresh_token_logging_interceptor_impl.dart'; +import 'package:fa_flutter_api_client/src/ssl_pinning/ssl_pinning_http_client_adapter.dart'; import 'package:fa_flutter_api_client/src/utils/constants.dart'; import 'package:fa_flutter_core/fa_flutter_core.dart' hide ProgressCallback; import 'package:http_parser/http_parser.dart'; @@ -15,6 +15,7 @@ class ApiServiceImpl implements ApiService { this.blobUrl, this.interceptors, this.apiOptions, + this.sslConfig, }) { _dio = Dio() ..options.contentType = Headers.jsonContentType @@ -39,15 +40,20 @@ class ApiServiceImpl implements ApiService { if (interceptors != null && interceptors!.isNotEmpty && isDebug) { _refreshTokenDio!.interceptors.add(RefreshTokenLoggingInterceptorImpl()); } + if (sslConfig != null) { + final sslPinningClientAdapter = SslPinningHttpClientAdapter(sslConfig!); + _dio!.httpClientAdapter = sslPinningClientAdapter; + _dioFile!.httpClientAdapter = sslPinningClientAdapter; + } } String baseUrl; String? blobUrl; - Dio? _dio; ApiOptions? apiOptions; + SslPinningConfig? sslConfig; + Dio? _dio; Dio? _refreshTokenDio; - Dio? _dioFile; final List? interceptors; @@ -166,8 +172,9 @@ class ApiServiceImpl implements ApiService { // if the endpoint is not passed use url parameter // if both of them are null then use default fileUploadUrl - endpoint = - endpoint != null ? "$baseUrl$endpoint" : url ?? getFileUploadUrl(); + endpoint = endpoint != null + ? "$baseUrl$endpoint" + : url ?? getFileUploadUrl(); if (queryParameters != null) { var queryUrl = ""; for (final parameter in queryParameters.entries) { @@ -298,8 +305,9 @@ class ApiServiceImpl implements ApiService { ProgressCallback? onSendProgress, Map? queryParameters, }) async { - endpoint = - endpoint != null ? "$baseUrl$endpoint" : url ?? getFileUploadUrl(); + endpoint = endpoint != null + ? "$baseUrl$endpoint" + : url ?? getFileUploadUrl(); if (queryParameters != null) { var queryUrl = ""; for (final parameter in queryParameters.entries) { diff --git a/lib/src/ssl_pinning/ssl_pinning_config.dart b/lib/src/ssl_pinning/ssl_pinning_config.dart new file mode 100644 index 0000000..edc32d1 --- /dev/null +++ b/lib/src/ssl_pinning/ssl_pinning_config.dart @@ -0,0 +1,32 @@ +/// SSL Certificate Pinning Configuration +class SslPinningConfig { + /// Map of domain to list of allowed SHA-256 fingerprints + final Map> _domainFingerprints; + + /// Whether to block requests to unpinned domains + final bool strictMode; + + /// Whether SSL pinning is enabled + final bool enabled; + + const SslPinningConfig({ + required Map> domainFingerprints, + this.strictMode = true, + this.enabled = true, + }) : _domainFingerprints = domainFingerprints; + + /// Check if a domain is pinned + bool isDomainPinned(String domain) { + return _domainFingerprints.containsKey(domain); + } + + /// Get fingerprints for a domain + List getFingerprintsForDomain(String domain) { + return _domainFingerprints[domain] ?? []; + } + + /// Get all pinned domains + List getPinnedDomains() { + return _domainFingerprints.keys.toList(); + } +} diff --git a/lib/src/ssl_pinning/ssl_pinning_http_client_adapter.dart b/lib/src/ssl_pinning/ssl_pinning_http_client_adapter.dart new file mode 100644 index 0000000..112a27d --- /dev/null +++ b/lib/src/ssl_pinning/ssl_pinning_http_client_adapter.dart @@ -0,0 +1,137 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:basic_utils/basic_utils.dart'; +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:fa_flutter_api_client/src/ssl_pinning/ssl_pinning_config.dart'; +import 'package:flutter/foundation.dart'; + +/// Custom Dio HttpClientAdapter with SSL Certificate Pinning +/// +/// This adapter validates SSL certificates against pinned SHA-256 fingerprints +/// for specified domains. It uses Dio's native HttpClientAdapter capabilities. +/// +/// Usage: +/// ```dart +/// final config = SslPinningConfig( +/// domainFingerprints: { +/// 'api.example.com': ['MWrOdUsfd6...'], +/// }, +/// strictMode: true, +/// ); +/// +/// final dio = Dio(); +/// dio.httpClientAdapter = SslPinningHttpClientAdapter(config); +/// ``` +class SslPinningHttpClientAdapter implements HttpClientAdapter { + final SslPinningConfig config; + final IOHttpClientAdapter _ioAdapter = IOHttpClientAdapter(); + + SslPinningHttpClientAdapter(this.config) { + // Configure the underlying adapter's HttpClient creation + _ioAdapter.createHttpClient = () { + // Create SecurityContext that doesn't trust any CA certificates by default + // This forces ALL certificates (valid or invalid) to go through badCertificateCallback + final securityContext = SecurityContext(withTrustedRoots: false); + + final client = HttpClient(context: securityContext); + + // CRITICAL: This callback now handles ALL certificates since we disabled trusted roots + // Every certificate must pass our fingerprint validation + client.badCertificateCallback = + (X509Certificate cert, String host, int port) { + return _validateCertificate(cert, host, port); + }; + + return client; + }; + } + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) { + return _ioAdapter.fetch(options, requestStream, cancelFuture); + } + + @override + void close({bool force = false}) { + _ioAdapter.close(force: force); + } + + /// Validate SSL certificate against pinned fingerprints + bool _validateCertificate(X509Certificate cert, String host, int port) { + if (!config.enabled) { + return true; + } + + // Check if domain is pinned + if (!config.isDomainPinned(host)) { + if (config.strictMode) { + // Strict mode: Block unpinned domains + return false; + } else { + // Permissive mode: Allow unpinned domains with standard TLS + return true; + } + } + + // Domain is pinned - validate certificate fingerprint + final expectedFingerprints = config.getFingerprintsForDomain(host); + + if (expectedFingerprints.isEmpty) { + return false; + } + + // Calculate SHA-256 fingerprint of the certificate + final certFingerprint = _getCertificateFingerprint(cert); + + // Check if certificate fingerprint matches any expected fingerprint + final isValid = expectedFingerprints.any((expected) { + // Normalize both fingerprints (remove colons, convert to uppercase) + final normalizedExpected = expected.replaceAll(':', '').toUpperCase(); + final normalizedCert = certFingerprint.replaceAll(':', '').toUpperCase(); + return normalizedExpected == normalizedCert; + }); + + return isValid; + } + + String _getCertificateFingerprint(X509Certificate cert) { + try { + final derBytes = cert.der; + final base64Der = base64.encode(derBytes); + final pem = + '-----BEGIN CERTIFICATE-----\n$base64Der\n-----END CERTIFICATE-----'; + + // Parse X509 certificate from PEM + final x509Cert = X509Utils.x509CertificateFromPem(pem); + + // Get the SHA-256 thumbprint of the public key (in hex format) + final thumbprintHex = + x509Cert.tbsCertificate?.subjectPublicKeyInfo.sha256Thumbprint ?? ''; + if (thumbprintHex.isEmpty) { + return ''; + } + // Convert hex string to bytes, then encode to base64 + final thumbprintBytes = _hexToBytes(thumbprintHex); + final fingerprint = base64Encode(thumbprintBytes); + return fingerprint; + } catch (e) { + return ''; + } + } + + /// Convert hex string to list of bytes + /// Example: '919C0DF7A787B597' -> [0x91, 0x9C, 0x0D, 0xF7, 0xA7, 0x87, 0xB5, 0x97] + List _hexToBytes(String hex) { + final result = []; + for (int i = 0; i < hex.length; i += 2) { + result.add(int.parse(hex.substring(i, i + 2), radix: 16)); + } + return result; + } +} diff --git a/pubspec.lock b/pubspec.lock index 7ff1206..3908527 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + basic_utils: + dependency: "direct main" + description: + name: basic_utils + sha256: "548047bef0b3b697be19fa62f46de54d99c9019a69fb7db92c69e19d87f633c7" + url: "https://pub.dev" + source: hosted + version: "5.8.2" boolean_selector: dependency: transitive description: @@ -161,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" cross_file: dependency: transitive description: @@ -923,6 +939,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" lottie: dependency: transitive description: @@ -1139,6 +1163,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" posix: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b35a960..f0a08d9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: dio: ^5.9.0 + basic_utils: ^5.8.2 # Core fa_flutter_core: