Solution
In Flutter, to once again make SSL https connections on older devices to Let's Encrypt SSL protected websites, we can supply Let's Encrypt's trusted certificate via SecurityContext
to dart:io
HttpClient
object (from the dart native communications library), which we can use directly to make https get/post calls, or we can supply that customized HttpClient
to Flutter/Dart package:http
IOClient
if we are using that popular pub.dev package.
Example
Here's a Flutter unit test which creates a dart:io
HttpClient
with a SecurityContext
that has a Let's Encrypt root certificate supplied to it. Then, this HttpClient
is provided to package:http
IOClient
which implement's the Client
interface and can be used for all the usual get
, post
etc. calls.
import 'dart:convert';
import 'dart:typed_data';
import 'dart:io';
import 'package:test/test.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
void main() {
const sslUrl = 'https://valid-isrgrootx1.letsencrypt.org/';
/// From dart:io, create a HttpClient with a trusted certificate [cert]
/// added to SecurityContext.
/// Wrapped in try catch in case the certificate is already trusted by
/// device/os, which will cause an exception to be thrown.
HttpClient customHttpClient({String cert}) {
SecurityContext context = SecurityContext.defaultContext;
try {
if (cert != null) {
Uint8List bytes = utf8.encode(cert);
context.setTrustedCertificatesBytes(bytes);
print('createHttpClient() - cert added!');
}
} on TlsException catch (e) {
if (e?.osError?.message != null &&
e.osError.message.contains('CERT_ALREADY_IN_HASH_TABLE')) {
print('createHttpClient() - cert already trusted! Skipping.');
} else {
print('createHttpClient().setTrustedCertificateBytes EXCEPTION: $e');
rethrow;
}
}
return new HttpClient(context: context);
}
/// Use package:http Client with our custom dart:io HttpClient with added
/// LetsEncrypt trusted certificate
http.Client createLEClient() {
IOClient ioClient;
ioClient = IOClient(customHttpClient(cert: ISRG_X1));
return ioClient;
}
/// Example using a custom package:http Client
/// that will work with devices missing LetsEncrypt
/// ISRG Root X1 certificates, like old Android 7 devices.
test('HTTP client to LetsEncrypt SSL website', () async {
http.Client _client = createLEClient();
http.Response _response = await _client.get(sslUrl);
print(_response.body);
expect(_response.statusCode, 200);
_client.close(); // remember to close client as per https://pub.dev/packages/http
});
}
/// This is LetsEncrypt's self-signed trusted root certificate authority
/// certificate, issued under common name: ISRG Root X1 (Internet Security
/// Research Group). Used in handshakes to negotiate a Transport Layer Security
/// connection between endpoints. This certificate is missing from older devices
/// that don't get OS updates such as Android 7 and older. But, we can supply
/// this certificate manually to our HttpClient via SecurityContext so it can be
/// used when connecting to URLs protected by LetsEncrypt SSL certificates.
/// PEM format LE self-signed cert from here: https://letsencrypt.org/certificates/
const String ISRG_X1 = """-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----""";
Since this unit test is run on a desktop/laptop computer which has the ISRG Root X1 certificate, it's probably not very interesting/useful. Systems which get updates will have this Certificate Authority (CA) certificate installed and "should" have no problems verifying the "chain of trust" for Let's Encrypt SSL certs.
But on old devices which don't have the ISRG Root X1 certificate and never will, using the two functions above customHttpClient()
and createLEClient()
we can make https/TLS connections to Let's Encrypt SSL protected Internet resources when LE's CA cert (ISRG Root X1) is missing.
Why this happened
Let's Encrypt SSL certificates are created/issued with a cross-sign from Digital Signature Trust (DST), an older, well-established Certificate Authority (CA).
Being cross-signed by a widely trusted CA meant Let's Ecrypt's (LE) SSL certs were accepted as legitimate by pretty much every application & device, from day 1 (roughly 5 years ago).
The certificate by DST used to cross-sign LE certs, expired on Sept. 30, 2021. This meant the "chain of trust" for LE certs is no longer accepted by some older devices.
There are several solutions to resolving this issue and this is just one way which doesn't require intervention by the end-user.
Why this is affecting Flutter on Android pre 7.1.1
(Here's my guess...)
The Dart VM (& therefore, Flutter) uses the BoringSSL library, a Google fork of OpenSSL.
BoringSSL in the Dart VM will stop searching for valid trust chains when any matching trust chain is found, invalid (i.e. expired) or otherwise. Google's Dart team ran across this issue in June (not because of Let's Encrypt's DST cross-sign expiration, but a similar issue) and created a patch for it on Aug 26. That patch could roll out with Dart 2.15. When that version of Dart is rolled into Flutter, I would hope/guess that this patch would fix this issue.
More Info
Background on expiration of DST root cert from LE
More background on DST expiration & cert chaining help from LE
Let's Encrypt has an ongoing mega-thread for the issues caused by the DST root cert expiration here