Flutter on Android 7 CERTIFICATE_VERIFY_FAILED with LetsEncrypt SSL cert after Sept 30, 2021

3,527

Solution 1

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

Solution 2

Thank you @esty92 and @3xecutor,

Download cert from https://letsencrypt.org/certs/lets-encrypt-r3.pem

Put the root certificate value in assets/ca:

You can also create a file with this name in assets/ca and copy this certificate init.

the file name "lets-encrypt-r3.pem"

-----BEGIN CERTIFICATE-----
MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw
WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP
R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx
sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm
NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg
Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG
/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC
AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB
Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA
FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw
AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw
Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB
gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W
PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl
ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz
CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm
lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4
avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2
yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O
yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids
hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+
HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv
MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX
nLRbwHOoq7hHwg==
-----END CERTIFICATE-----

then add the assets/ca path tp your pubspec.yaml like this

 flutter:
  uses-material-design: true
  assets:
     .
     .
     .
    - assets/ca/

Then simply add this in your main.dart:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  ByteData data = await PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem');
  SecurityContext.defaultContext.setTrustedCertificatesBytes(data.buffer.asUint8List());

  runApp(MyApp());
}

This method solved my problem and It works for androids with version lower than 7.1.1.

Solution 3

To trust the new ISRG certificate (see the answer of @Baker and @Westy92) globally, just add this at the beginning of main()

try {
  SecurityContext.defaultContext.setTrustedCertificatesBytes(ascii.encode(ISRG_X1));
} catch (e) {
  // ignore errors here, maybe it's already trusted
}

This would make the certificate trusted in all HttpClient instances including NetworkImage, CachedNetworkImage and grpc.

Solution 4

Thank you @esty92,

Finally I even got a shorter solution:

Put the root certificate value in assets/isrg_x1.pem:

    -----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-----

Then simply add this in your dio setup:

dio = new Dio();
ByteData bytes = await rootBundle.load('assets/isrg_x1.pem');
(httpClient.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) {
  SecurityContext sc = new SecurityContext();
  sc.setTrustedCertificatesBytes(bytes.buffer.asUint8List());
  HttpClient httpClient = new HttpClient(context: sc);
  return httpClient;
};

But be careful, it doesn't apply to NetworkImage, etc... you need to add the certificate at the flutter app init.

Share:
3,527
Baker
Author by

Baker

Updated on January 01, 2023

Comments

  • Baker
    Baker over 1 year

    After Sept 30, 2021, https get/post requests to a website using a Let's Encrypt SSL ceritificate on an old Android 7 device were failing with this error:

    HandshakeException: Handshake error in client (OS Error: CERTIFICATE_VERIFY_FAILED: certificate has expired(handshake.cc:354))
    

    This error doesn't occur on newer Android nor Apple devices.

    Why did this error suddently start on old Android phones?

    How can I resolve this?

  • Julio Henrique Bitencourt
    Julio Henrique Bitencourt over 2 years
    Just as a reminder, if you're creating the client, can't forget to call client.close() though.
  • Baker
    Baker over 2 years
    @JulioHenriqueBitencourt Good reminder. I've added that to the unit test to help stress that.
  • Eng
    Eng over 2 years
    Can we use this for production with device running android >7 ?
  • Baker
    Baker over 2 years
    @Eng I believe so. The code includes a try/catch for when an ISRG Root X1 cert is already present (for Android >7), which would throw a TlsException of CERT_ALREADY_IN_HASH_TABLE. That exception would be caught & ignored. Other exceptions would be bubbled up.
  • dev_mush
    dev_mush over 2 years
    I don't get something. ISRG Root X1 should supposedly work with android versions prior 7.1.1 thanks to cross signing as stated here letsencrypt.org/docs/certificate-compatibility so why is this happening?
  • Baker
    Baker over 2 years
    @dev_mush added a section "Why this is affecting Flutter on Android pre 7.1.1". That's my guess on why this issue affects Flutter even with Android supposedly ignoring trusted root cert expiration dates.
  • dev_mush
    dev_mush over 2 years
    Yep, it must be it, after doing some research I also found out that versions of openssl prior to 1.1 had a bug, so if something uses that lib the problem is present as well.
  • daraul
    daraul over 2 years
    So, theoretically, updating my flutter version might fix this problem? Perhaps not right now, but sometime soon?
  • Baker
    Baker over 2 years
    @daraul On Android platforms, pre-7.1.1, my guess is yes, a future Flutter version using Dart 2.15 (currently 2.14 as of Dec 2021), could take advantage of old Android's ignoring expiry dates of trusted certificates. Just a guess. I don't think this helps iOS though.
  • Suraj
    Suraj about 2 years
    Thanks for the answer. Tried this solution and its working fine.