Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/fa_flutter_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
22 changes: 15 additions & 7 deletions lib/src/api_service_impl.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +15,7 @@ class ApiServiceImpl implements ApiService {
this.blobUrl,
this.interceptors,
this.apiOptions,
this.sslConfig,
}) {
_dio = Dio()
..options.contentType = Headers.jsonContentType
Expand All @@ -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<Interceptor>? interceptors;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -298,8 +305,9 @@ class ApiServiceImpl implements ApiService {
ProgressCallback? onSendProgress,
Map<String, dynamic>? 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) {
Expand Down
32 changes: 32 additions & 0 deletions lib/src/ssl_pinning/ssl_pinning_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/// SSL Certificate Pinning Configuration
class SslPinningConfig {
/// Map of domain to list of allowed SHA-256 fingerprints
final Map<String, List<String>> _domainFingerprints;

/// Whether to block requests to unpinned domains
final bool strictMode;

/// Whether SSL pinning is enabled
final bool enabled;

const SslPinningConfig({
required Map<String, List<String>> 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<String> getFingerprintsForDomain(String domain) {
return _domainFingerprints[domain] ?? [];
}

/// Get all pinned domains
List<String> getPinnedDomains() {
return _domainFingerprints.keys.toList();
}
}
137 changes: 137 additions & 0 deletions lib/src/ssl_pinning/ssl_pinning_http_client_adapter.dart
Original file line number Diff line number Diff line change
@@ -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<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? 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<int> _hexToBytes(String hex) {
final result = <int>[];
for (int i = 0; i < hex.length; i += 2) {
result.add(int.parse(hex.substring(i, i + 2), radix: 16));
}
return result;
}
}
32 changes: 32 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ environment:

dependencies:
dio: ^5.9.0
basic_utils: ^5.8.2

# Core
fa_flutter_core:
Expand Down
Loading