Skip to content

Commit 5b4ecb0

Browse files
vatsal201singhtaranjeet
authored andcommitted
feat(ssl): implement ssl pinning client adapter
Signed-off-by: Vatsal Gandhi <gandhivatsal17@gmail.com>
1 parent d0b3f35 commit 5b4ecb0

6 files changed

Lines changed: 218 additions & 7 deletions

File tree

lib/fa_flutter_api_client.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export 'src/api_options/api_options.dart';
1111
export 'src/interceptors/cache_interceptor.dart';
1212
export 'src/interceptors/cancel_token_interceptor.dart';
1313
export 'src/interceptors/refresh_token_interceptor.dart';
14+
export 'src/ssl_pinning/ssl_pinning_config.dart';

lib/src/api_service_impl.dart

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import 'dart:convert';
21
import 'dart:io';
32

43
import 'package:dio/dio.dart';
54
import 'package:fa_flutter_api_client/fa_flutter_api_client.dart';
65
import 'package:fa_flutter_api_client/src/implementations/refresh_token_logging_interceptor_impl.dart';
6+
import 'package:fa_flutter_api_client/src/ssl_pinning/ssl_pinning_http_client_adapter.dart';
77
import 'package:fa_flutter_api_client/src/utils/constants.dart';
88
import 'package:fa_flutter_core/fa_flutter_core.dart' hide ProgressCallback;
99
import 'package:http_parser/http_parser.dart';
@@ -15,6 +15,7 @@ class ApiServiceImpl implements ApiService {
1515
this.blobUrl,
1616
this.interceptors,
1717
this.apiOptions,
18+
this.sslConfig,
1819
}) {
1920
_dio = Dio()
2021
..options.contentType = Headers.jsonContentType
@@ -39,15 +40,20 @@ class ApiServiceImpl implements ApiService {
3940
if (interceptors != null && interceptors!.isNotEmpty && isDebug) {
4041
_refreshTokenDio!.interceptors.add(RefreshTokenLoggingInterceptorImpl());
4142
}
43+
if (sslConfig != null) {
44+
final sslPinningClientAdapter = SslPinningHttpClientAdapter(sslConfig!);
45+
_dio!.httpClientAdapter = sslPinningClientAdapter;
46+
_dioFile!.httpClientAdapter = sslPinningClientAdapter;
47+
}
4248
}
4349

4450
String baseUrl;
4551
String? blobUrl;
46-
Dio? _dio;
4752
ApiOptions? apiOptions;
53+
SslPinningConfig? sslConfig;
4854

55+
Dio? _dio;
4956
Dio? _refreshTokenDio;
50-
5157
Dio? _dioFile;
5258

5359
final List<Interceptor>? interceptors;
@@ -166,8 +172,9 @@ class ApiServiceImpl implements ApiService {
166172
// if the endpoint is not passed use url parameter
167173
// if both of them are null then use default fileUploadUrl
168174

169-
endpoint =
170-
endpoint != null ? "$baseUrl$endpoint" : url ?? getFileUploadUrl();
175+
endpoint = endpoint != null
176+
? "$baseUrl$endpoint"
177+
: url ?? getFileUploadUrl();
171178
if (queryParameters != null) {
172179
var queryUrl = "";
173180
for (final parameter in queryParameters.entries) {
@@ -298,8 +305,9 @@ class ApiServiceImpl implements ApiService {
298305
ProgressCallback? onSendProgress,
299306
Map<String, dynamic>? queryParameters,
300307
}) async {
301-
endpoint =
302-
endpoint != null ? "$baseUrl$endpoint" : url ?? getFileUploadUrl();
308+
endpoint = endpoint != null
309+
? "$baseUrl$endpoint"
310+
: url ?? getFileUploadUrl();
303311
if (queryParameters != null) {
304312
var queryUrl = "";
305313
for (final parameter in queryParameters.entries) {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/// SSL Certificate Pinning Configuration
2+
class SslPinningConfig {
3+
/// Map of domain to list of allowed SHA-256 fingerprints
4+
final Map<String, List<String>> _domainFingerprints;
5+
6+
/// Whether to block requests to unpinned domains
7+
final bool strictMode;
8+
9+
/// Whether SSL pinning is enabled
10+
final bool enabled;
11+
12+
const SslPinningConfig({
13+
required Map<String, List<String>> domainFingerprints,
14+
this.strictMode = true,
15+
this.enabled = true,
16+
}) : _domainFingerprints = domainFingerprints;
17+
18+
/// Check if a domain is pinned
19+
bool isDomainPinned(String domain) {
20+
return _domainFingerprints.containsKey(domain);
21+
}
22+
23+
/// Get fingerprints for a domain
24+
List<String> getFingerprintsForDomain(String domain) {
25+
return _domainFingerprints[domain] ?? [];
26+
}
27+
28+
/// Get all pinned domains
29+
List<String> getPinnedDomains() {
30+
return _domainFingerprints.keys.toList();
31+
}
32+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:basic_utils/basic_utils.dart';
5+
import 'package:dio/dio.dart';
6+
import 'package:dio/io.dart';
7+
import 'package:fa_flutter_api_client/src/ssl_pinning/ssl_pinning_config.dart';
8+
import 'package:flutter/foundation.dart';
9+
10+
/// Custom Dio HttpClientAdapter with SSL Certificate Pinning
11+
///
12+
/// This adapter validates SSL certificates against pinned SHA-256 fingerprints
13+
/// for specified domains. It uses Dio's native HttpClientAdapter capabilities.
14+
///
15+
/// Usage:
16+
/// ```dart
17+
/// final config = SslPinningConfig(
18+
/// domainFingerprints: {
19+
/// 'api.example.com': ['MWrOdUsfd6...'],
20+
/// },
21+
/// strictMode: true,
22+
/// );
23+
///
24+
/// final dio = Dio();
25+
/// dio.httpClientAdapter = SslPinningHttpClientAdapter(config);
26+
/// ```
27+
class SslPinningHttpClientAdapter implements HttpClientAdapter {
28+
final SslPinningConfig config;
29+
final IOHttpClientAdapter _ioAdapter = IOHttpClientAdapter();
30+
31+
SslPinningHttpClientAdapter(this.config) {
32+
// Configure the underlying adapter's HttpClient creation
33+
_ioAdapter.createHttpClient = () {
34+
// Create SecurityContext that doesn't trust any CA certificates by default
35+
// This forces ALL certificates (valid or invalid) to go through badCertificateCallback
36+
final securityContext = SecurityContext(withTrustedRoots: false);
37+
38+
final client = HttpClient(context: securityContext);
39+
40+
// CRITICAL: This callback now handles ALL certificates since we disabled trusted roots
41+
// Every certificate must pass our fingerprint validation
42+
client.badCertificateCallback =
43+
(X509Certificate cert, String host, int port) {
44+
return _validateCertificate(cert, host, port);
45+
};
46+
47+
return client;
48+
};
49+
}
50+
51+
@override
52+
Future<ResponseBody> fetch(
53+
RequestOptions options,
54+
Stream<Uint8List>? requestStream,
55+
Future<void>? cancelFuture,
56+
) {
57+
return _ioAdapter.fetch(options, requestStream, cancelFuture);
58+
}
59+
60+
@override
61+
void close({bool force = false}) {
62+
_ioAdapter.close(force: force);
63+
}
64+
65+
/// Validate SSL certificate against pinned fingerprints
66+
bool _validateCertificate(X509Certificate cert, String host, int port) {
67+
if (!config.enabled) {
68+
return true;
69+
}
70+
71+
// Check if domain is pinned
72+
if (!config.isDomainPinned(host)) {
73+
if (config.strictMode) {
74+
// Strict mode: Block unpinned domains
75+
return false;
76+
} else {
77+
// Permissive mode: Allow unpinned domains with standard TLS
78+
return true;
79+
}
80+
}
81+
82+
// Domain is pinned - validate certificate fingerprint
83+
final expectedFingerprints = config.getFingerprintsForDomain(host);
84+
85+
if (expectedFingerprints.isEmpty) {
86+
return false;
87+
}
88+
89+
// Calculate SHA-256 fingerprint of the certificate
90+
final certFingerprint = _getCertificateFingerprint(cert);
91+
92+
// Check if certificate fingerprint matches any expected fingerprint
93+
final isValid = expectedFingerprints.any((expected) {
94+
// Normalize both fingerprints (remove colons, convert to uppercase)
95+
final normalizedExpected = expected.replaceAll(':', '').toUpperCase();
96+
final normalizedCert = certFingerprint.replaceAll(':', '').toUpperCase();
97+
return normalizedExpected == normalizedCert;
98+
});
99+
100+
return isValid;
101+
}
102+
103+
String _getCertificateFingerprint(X509Certificate cert) {
104+
try {
105+
final derBytes = cert.der;
106+
final base64Der = base64.encode(derBytes);
107+
final pem =
108+
'-----BEGIN CERTIFICATE-----\n$base64Der\n-----END CERTIFICATE-----';
109+
110+
// Parse X509 certificate from PEM
111+
final x509Cert = X509Utils.x509CertificateFromPem(pem);
112+
113+
// Get the SHA-256 thumbprint of the public key (in hex format)
114+
final thumbprintHex =
115+
x509Cert.tbsCertificate?.subjectPublicKeyInfo.sha256Thumbprint ?? '';
116+
if (thumbprintHex.isEmpty) {
117+
return '';
118+
}
119+
// Convert hex string to bytes, then encode to base64
120+
final thumbprintBytes = _hexToBytes(thumbprintHex);
121+
final fingerprint = base64Encode(thumbprintBytes);
122+
return fingerprint;
123+
} catch (e) {
124+
return '';
125+
}
126+
}
127+
128+
/// Convert hex string to list of bytes
129+
/// Example: '919C0DF7A787B597' -> [0x91, 0x9C, 0x0D, 0xF7, 0xA7, 0x87, 0xB5, 0x97]
130+
List<int> _hexToBytes(String hex) {
131+
final result = <int>[];
132+
for (int i = 0; i < hex.length; i += 2) {
133+
result.add(int.parse(hex.substring(i, i + 2), radix: 16));
134+
}
135+
return result;
136+
}
137+
}

pubspec.lock

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ packages:
4141
url: "https://pub.dev"
4242
source: hosted
4343
version: "2.13.0"
44+
basic_utils:
45+
dependency: "direct main"
46+
description:
47+
name: basic_utils
48+
sha256: "548047bef0b3b697be19fa62f46de54d99c9019a69fb7db92c69e19d87f633c7"
49+
url: "https://pub.dev"
50+
source: hosted
51+
version: "5.8.2"
4452
boolean_selector:
4553
dependency: transitive
4654
description:
@@ -161,6 +169,14 @@ packages:
161169
url: "https://pub.dev"
162170
source: hosted
163171
version: "2.0.1"
172+
convert:
173+
dependency: transitive
174+
description:
175+
name: convert
176+
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
177+
url: "https://pub.dev"
178+
source: hosted
179+
version: "3.1.2"
164180
cross_file:
165181
dependency: transitive
166182
description:
@@ -923,6 +939,14 @@ packages:
923939
url: "https://pub.dev"
924940
source: hosted
925941
version: "2.6.2"
942+
logging:
943+
dependency: transitive
944+
description:
945+
name: logging
946+
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
947+
url: "https://pub.dev"
948+
source: hosted
949+
version: "1.3.0"
926950
lottie:
927951
dependency: transitive
928952
description:
@@ -1139,6 +1163,14 @@ packages:
11391163
url: "https://pub.dev"
11401164
source: hosted
11411165
version: "2.1.8"
1166+
pointycastle:
1167+
dependency: transitive
1168+
description:
1169+
name: pointycastle
1170+
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
1171+
url: "https://pub.dev"
1172+
source: hosted
1173+
version: "4.0.0"
11421174
posix:
11431175
dependency: transitive
11441176
description:

pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ environment:
88

99
dependencies:
1010
dio: ^5.9.0
11+
basic_utils: ^5.8.2
1112

1213
# Core
1314
fa_flutter_core:

0 commit comments

Comments
 (0)