Skip to content

Commit

Permalink
fix(pinning): add callback for leaf certificate for better pinning (c…
Browse files Browse the repository at this point in the history
…fug#21)

Co-authored-by: Tim Shadel <[email protected]>
  • Loading branch information
AlexV525 and timshadel authored Nov 15, 2022
1 parent a8a9fd8 commit fcd076a
Show file tree
Hide file tree
Showing 17 changed files with 505 additions and 36 deletions.
17 changes: 6 additions & 11 deletions .github/workflows/dio.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,17 @@ jobs:
matrix:
sdk: [ stable, beta ]
platform: [ vm, chrome ]
defaults:
run:
working-directory: dio
steps:
- uses: actions/checkout@v3
- uses: dart-lang/setup-dart@v1
with:
sdk: ${{ matrix.sdk }}
- run: dart pub get
- run: dart test --chain-stack-traces --platform=${{ matrix.platform }}
- name: Upload test report
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results-${{ matrix.sdk }}-${{ matrix.platform }}
path: dio/build/reports/test-results.json
- run: |
chmod +x ./scripts/prepare_pinning_certs.sh
./scripts/prepare_pinning_certs.sh
shell: bash
- run: cd dio && dart pub get
- run: cd dio && dart test --chain-stack-traces --platform=${{ matrix.platform }}

publish-dry-run:
name: Publish dry-run
Expand Down
16 changes: 5 additions & 11 deletions .github/workflows/plugin_http2_adapter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,13 @@ jobs:
fail-fast: false
matrix:
sdk: [ stable, beta ]
defaults:
run:
working-directory: plugins/http2_adapter
steps:
- uses: actions/checkout@v3
- uses: dart-lang/setup-dart@v1
with:
sdk: ${{ matrix.sdk }}
- run: dart pub get
- run: dart test --chain-stack-traces
- name: Upload test report
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results-${{ matrix.sdk }}
path: plugins/http2_adapter/build/reports/test-results.json
- run: |
chmod +x ./scripts/prepare_pinning_certs.sh
./scripts/prepare_pinning_certs.sh
- run: cd plugins/http2_adapter && dart pub get
- run: cd plugins/http2_adapter && dart test --chain-stack-traces
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dio/.idea/
dio/.exampl
dio/coverage
dio/test/_download_test.md
dio/test/*_pinning.txt

# plugins
plugins/cookie_manager/.packages
Expand All @@ -32,6 +33,7 @@ plugins/http2_adapter/.dart_tool/
plugins/http2_adapter/.pub/
plugins/http2_adapter/.idea/
plugins/http2_adapter/.exampl
plugins/http2_adapter/test/*_pinning.txt

.vscode/

Expand Down
51 changes: 46 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -686,13 +686,54 @@ There is a complete example [here](https://github.com/flutterchina/dio/blob/mast

### Https certificate verification

There are two ways to verify the https certificate. Suppose the certificate format is PEM, the code like:
HTTPS certificate verification (or public key pinning) refers to the process of ensuring that the certificates protecting the TLS connection to the server are the ones you expect them to be. The intention is to reduce the chance of a man-in-the-middle attack. The theory is covered by [OWASP](https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning).

_Server Response Certificate_

Unlike other methods, this one works with the certificate of the server itself.

```dart
String fingerprint = 'ee5ce1dfa7a53657c545c62b65802e4272878dabd65c0aadcf85783ebb0b4d5c';
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
(_) {
// Don't trust any certificate just because their root cert is trusted
final client = HttpClient(context: SecurityContext(withTrustedRoots: false));
// You can test the intermediate / root cert here. We just ignore it.
client.badCertificateCallback = (cert, host, port) => true;
return client;
};
// Check that the cert fingerprint matches the one we expect
(dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate =
(cert, host, port) {
// We definitely require _some_ certificate
if (cert == null) return false;
// Validate it any way you want. Here we only check that
// the fingerprint matches the OpenSSL SHA256.
return fingerprint == sha256.convert(cert.der).toString();
};
```

You can use openssl to read the SHA256 value of a certificate:

```sh
openssl s_client -servername pinning-test.badssl.com -connect pinning-test.badssl.com:443 < /dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256

# SHA256 Fingerprint=EE:5C:E1:DF:A7:A5:36:57:C5:45:C6:2B:65:80:2E:42:72:87:8D:AB:D6:5C:0A:AD:CF:85:78:3E:BB:0B:4D:5C
# (remove the formatting, keep only lower case hex characters to match the `sha256` above)
```

_Certificate Authority Verification_

These methods work well when your server has a self-signed certificate, but they don't work for certificates issued by a 3rd party like AWS or Let's Encrypt.

There are two ways to verify the root of the https certificate chain provided by the server. Suppose the certificate format is PEM, the code like:

```dart
String PEM='XXXXX'; // certificate content
String PEM='XXXXX'; // root certificate content
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) {
client.badCertificateCallback=(X509Certificate cert, String host, int port){
if(cert.pem==PEM){ // Verify the certificate
if(cert.pem==PEM){ // Verify the root certificate
return true;
}
return false;
Expand All @@ -705,14 +746,14 @@ Another way is creating a `SecurityContext` when create the `HttpClient`:
```dart
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) {
SecurityContext sc = SecurityContext();
//file is the path of certificate
//file is the path of root certificate
sc.setTrustedCertificates(file);
HttpClient httpClient = HttpClient(context: sc);
return httpClient;
};
```

In this way, the format of certificate must be PEM or PKCS12.
In this way, the format of certificate must be PEM or PKCS12.

## Http2 support

Expand Down
1 change: 1 addition & 0 deletions dio/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# 5.0.0-dev.1

- A platform independend `HttpClientAdapter` can now be instantiated by doing `dio.httpClientAdapter = HttpClientAdapter();`.
- Add `ValidateCertificate` to handle certificate pinning better.

## Breaking Changes

Expand Down
27 changes: 26 additions & 1 deletion dio/lib/src/adapters/io_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import '../options.dart';
import '../dio_error.dart';
import '../redirect_record.dart';

@Deprecated('Use IOHttpClientAdapter instead')
@Deprecated('Use IOHttpClientAdapter instead. This will be removed in 6.0.0')
typedef DefaultHttpClientAdapter = IOHttpClientAdapter;

typedef OnHttpClientCreate = HttpClient? Function(HttpClient client);
typedef ValidateCertificate = bool Function(
X509Certificate? certificate, String host, int port);

HttpClientAdapter createAdapter() => IOHttpClientAdapter();

Expand All @@ -20,6 +22,14 @@ class IOHttpClientAdapter implements HttpClientAdapter {
/// it when a HttpClient created.
OnHttpClientCreate? onHttpClientCreate;

/// Allows the user to decide if the response certificate is good.
/// If this function is missing, then the certificate is allowed.
/// This method is called only if both the [SecurityContext] and
/// [badCertificateCallback] accept the certificate chain. Those
/// methods evaluate the root or intermediate certificate, while
/// [validateCertificate] evaluates the leaf certificate.
ValidateCertificate? validateCertificate;

HttpClient? _defaultHttpClient;

bool _closed = false;
Expand Down Expand Up @@ -113,6 +123,21 @@ class IOHttpClientAdapter implements HttpClientAdapter {

final responseStream = await future;

if (validateCertificate != null) {
final host = options.uri.host;
final port = options.uri.port;
final isCertApproved =
validateCertificate!(responseStream.certificate, host, port);
if (!isCertApproved) {
throw DioError(
requestOptions: options,
type: DioErrorType.badCertificate,
error: responseStream.certificate,
message: 'The certificate of the response is not approved.',
);
}
}

var stream =
responseStream.transform<Uint8List>(StreamTransformer.fromHandlers(
handleData: (data, sink) {
Expand Down
5 changes: 5 additions & 0 deletions dio/lib/src/dio_error.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ enum DioErrorType {
///It occurs when receiving timeout.
receiveTimeout,

/// Caused by an incorrect certificate as configured by [ValidateCertificate].
badCertificate,

/// The [DioError] was caused by an incorrect status code as configured by
/// [ValidateStatus].
badResponse,
Expand All @@ -35,6 +38,8 @@ extension _DioErrorTypeExtension on DioErrorType {
return 'send timeout';
case DioErrorType.receiveTimeout:
return 'receive timeout';
case DioErrorType.badCertificate:
return 'bad certificate';
case DioErrorType.badResponse:
return 'bad response';
case DioErrorType.cancel:
Expand Down
3 changes: 2 additions & 1 deletion dio/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ dependencies:
dev_dependencies:
lints: ^1.0.1
test: ^1.5.1
coverage: ^1.0.3
coverage: ^1.0.3
crypto: ^3.0.2
6 changes: 2 additions & 4 deletions dio/test/exception_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:test/test.dart';
import 'package:dio/io.dart';
Expand Down Expand Up @@ -48,9 +47,8 @@ void main() {
test('allow badssl', () async {
var dio = Dio();
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
(HttpClient client) {
client.badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
(client) {
return client..badCertificateCallback = (cert, host, port) => true;
};
var response = await dio.get('https://wrong.host.badssl.com/');
expect(response.statusCode, 200);
Expand Down
144 changes: 144 additions & 0 deletions dio/test/pinning_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
@TestOn('vm')
import 'dart:io';

import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:test/test.dart';
import 'package:dio/io.dart';

void main() {
// NOTE: Run test.sh to download the currrent certs to the file below.
final trustedCertUrl = 'https://sha256.badssl.com/';
final untrustedCertUrl = 'https://wrong.host.badssl.com/';

// OpenSSL output like: SHA256 Fingerprint=EE:5C:E1:DF:A7:A4...
// All badssl.com hosts have the same cert, they just have TLS
// setting or other differences (like host name) that make them bad.
final lines = File('test/_pinning.txt').readAsLinesSync();
final fingerprint =
lines.first.split('=').last.toLowerCase().replaceAll(':', '');

test('pinning: trusted host allowed with no approver', () async {
await Dio().get(trustedCertUrl);
});

test('pinning: untrusted host rejected with no approver', () async {
dynamic error;

try {
var dio = Dio();
await dio.get(untrustedCertUrl);
fail('did not throw');
} on DioError catch (e) {
error = e;
}
expect(error, isNotNull);
expect(error is Exception, isTrue);
});

test('pinning: every certificate tested and rejected', () async {
dynamic error;

try {
var dio = Dio();
(dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate =
(certificate, host, port) => false;
await dio.get(trustedCertUrl);
fail('did not throw');
} on DioError catch (e) {
error = e;
}
expect(error, isNotNull);
expect(error is Exception, isTrue);
});

test('pinning: trusted certificate tested and allowed', () async {
var dio = Dio();
// badCertificateCallback never called for trusted certificate
(dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate =
(cert, host, port) =>
fingerprint == sha256.convert(cert!.der).toString();
final response = await dio.get(trustedCertUrl,
options: Options(validateStatus: (status) => true));
expect(response, isNotNull);
});

test('pinning: untrusted certificate tested and allowed', () async {
var dio = Dio();
// badCertificateCallback must allow the untrusted certificate through
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
(client) {
return client..badCertificateCallback = (cert, host, port) => true;
};
(dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate =
(cert, host, port) =>
fingerprint == sha256.convert(cert!.der).toString();
final response = await dio.get(untrustedCertUrl,
options: Options(validateStatus: (status) => true));
expect(response, isNotNull);
});

test('pinning: untrusted certificate rejected before validateCertificate',
() async {
dynamic error;

try {
var dio = Dio();
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
(_) => HttpClient(context: SecurityContext(withTrustedRoots: false));
(dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate =
(cert, host, port) => fail('Should not be evaluated');
await dio.get(untrustedCertUrl,
options: Options(validateStatus: (status) => true));
fail('did not throw');
} on DioError catch (e) {
error = e;
}
expect(error, isNotNull);
expect(error is Exception, isTrue);
});

test('bad pinning: badCertCallback does not use leaf certificate', () async {
dynamic error;

try {
var dio = Dio();
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
(HttpClient client) {
final effectiveClient =
HttpClient(context: SecurityContext(withTrustedRoots: false));
// Comparison fails because fingerprint is for leaf cert, but
// this cert is from Let's Encrypt.
effectiveClient.badCertificateCallback =
(X509Certificate cert, String host, int port) =>
fingerprint == sha256.convert(cert.der).toString();
return effectiveClient;
};
await dio.get(trustedCertUrl,
options: Options(validateStatus: (status) => true));
fail('did not throw');
} on DioError catch (e) {
error = e;
}
expect(error, isNotNull);
expect(error is Exception, isTrue);
});

test('pinning: 2 requests == 2 approvals', () async {
int approvalCount = 0;
var dio = Dio();
// badCertificateCallback never called for trusted certificate
(dio.httpClientAdapter as DefaultHttpClientAdapter).validateCertificate =
(cert, host, port) {
approvalCount++;
return fingerprint == sha256.convert(cert!.der).toString();
};
Response response = await dio.get(trustedCertUrl,
options: Options(validateStatus: (status) => true));
expect(response.data, isNotNull);
response = await dio.get(trustedCertUrl,
options: Options(validateStatus: (status) => true));
expect(response.data, isNotNull);
expect(approvalCount, 2);
});
}
Loading

0 comments on commit fcd076a

Please sign in to comment.