Chris Dzombak

sharing preview • dzombak.com

HTTPS Requests with a Root Certificate Store on ESP8266 + Arduino

HTTPS Requests with a Root Certificate Store on ESP8266 + Arduino

Part of the Getting Started with ESP8266 + Arduino series.

As part of my exploration of the basics of the ESP8266/Arduino/PlatformIO development experience, I wanted to learn how to make HTTPS requests.

I figured this would be more challenging than using Golang on a Raspberry Pi, for example, because TLS certificate verification requires knowing the current time and knowing which root certificate(s) are trustworthy. Both of these are free if you’re running Linux, like the Raspberry Pi, but on a microcontroller you’ll have to do it yourself. Luckily, the Arduino system and the ESP8266 Arduino core make both of those tasks pretty easy.

Basic Setup

There is a “basic HTTPS client” example, but it doesn’t do a very good job of demonstrating how to use the Arduino/ESP8266 HTTPClient class (which is used to actually make HTTP requests) with the WiFiClientSecure class.

We start by declaring a global, shared WiFiClientSecure:

#include <WiFiClientSecureBearSSL.h>
BearSSL::WiFiClientSecure wifiClient;

Then, pass that wifiClient when calling .begin(…) on our HTTP client. Here’s how that looks (with error-handling and other code removed for clarity):

#include <ESP8266HTTPClient.h>

    // ...
    HTTPClient httpClient;
    httpClient.begin(wifiClient, "https://ip.dzdz.cz");
    int respCode = httpClient.GET();
    if (respCode >= 400) {
        // HTTP error
    } else if (respCode > 0) {
        String result = httpClient.getString();
        // Do something with result...
    } else {
        // Other error occurred
    }
    httpClient.end();
    // ...

First, though, we’ll need to tell the wifiClient how to verify TLS certificates. Let’s discuss our options…

Certificate Verification Methods

Certificate Store

Docs & examples: BearSSL_CertStore example; Core documentation

This was the approach I used for this demo, because will work with the widest array of servers and closely mirrors how I’m used to this process working. (This is how pretty much everything on your computer & phone verifies certificates.) This method involves generating a list of trusted root certificates, writing it to your ESP8266’s flash memory (either in LittleFS or SPIFFS), and then telling the WiFiClientSecure to use it to verify certificates.

This approach does have a downside: the need to figure out which certificates to include a root certificate store. The most common approach here is to download and use the full set of root certificates included in Mozilla’s CA Certificate Program. My friend David Adrian notes that this isn’t a perfect approach from a security standpoint; this list is intended to support web PKI across a range of applications, not just TLS connections, so you’ll end up including some root certificates that shouldn’t be used for HTTPS. He also notes, “there’s not really a better option.” If using this approach, you should be prepare to update your root certificate store. For more on this topic, check out the “How to be a Certificate Authority, feat. Ryan Sleevi” episode of the “Security. Cryptography. Whatever.” podcast.

I’ll walk through using a certificate store in more detail below; first, a discussion and some links on other methods you might use:

X509List

Docs & examples: BearSSL_Validation example; Core documentation

I haven’t played with this, but based on the documentation and examples, this is conceptually similar to the Certificate Store. This class (BearSSL::X509List) lets you tell the WifiClientSecure to trust one or more certificates.

The difference from BearSSL::CertStore is that these certificates are stored in RAM, rather than stored in LittleFS (or SPIFFS) on the device. You can see this in the example code — it uses a certificate defined as a literal in a header, rather than reading it from flash as the CertStore example does.

(Incidentally, the documentation and example linked above don’t cover adding multiple certificates; to do that, look into the X509List’s append methods.

This is clearly simpler and easier than using a CertStore, but it’ll take up more and more of the device’s limited RAM as you add certificates. Use this if you only need/want to trust one or a few certificate authorities for your application.

In a future blog post, I’ll explore using X509List in an application where I know I only need to trust certificates from Let’s Encrypt.

Update October 29, 2021: You can read that post here.

Pinned public key (fragile)

Docs & examples: BearSSL_Validation example; Core documentation

With this approach, you simply embed the server’s public key in your code, and tell BearSSL only to trust certificates which use that key.

Though straightforward, this approach has some downsides:

Check certificate fingerprint (very fragile)

Docs & examples: BearSSL_Validation example; Core documentation

Using this approach, you hardcode the cryptographic fingerprint of the server’s TLS certificate in your code. I’ve seen a lot of Arduino example code using this approach, and while it is more-or-less secure, this approach has significant downsides:

If you really can’t use a certificate store or X509List, you should consider using a pinned public key instead of a hardcoded certificate fingerprint.

Skip verification (insecure)

It’s possible to tell WiFiClientSecure to completely skip verification, trusting absolutely any certificate presented for any server. This is completely insecure, and you should not do this.

Allow any self-signed certificate (insecure)

It’s also possible to configure WiFiClientSecure to accept any self-signed certificate. This is also totally insecure; it’s no better security-wise than completely skipping verification, and you should not do this. I don’t know why this option even exists.

Okay, fine, I can imagine certain esoteric threat models this could theoretically be slightly better than completely skipping verification, but I sincerely doubt that your threat model qualifies, and if you think it does, it’s probably wrong.

Making HTTPS Requests Using a Certificate Store

Now the fun part: how do we use a certificate store for HTTPS connections on ESP8266/Arduino?

Prerequisite: Generating the Certificate Store

First, we’ll need to generate a certificate store to flash to the device. The most common — really, the only practical way to do this as a hobbyist — is to use Mozilla’s list of trusted root certificates.

Download and run this Python script, certs-from-mozilla.py. This script works on macOS, and it ought to work just fine on Linux. It will fetch Mozilla’s current list of trusted root certificates and pack them into an archive which you can flash to your ESP8266’s memory.

It’s simple to use:

$ ./certs-from-mozilla.py
AC Camerfirma, S.A.:AC Camerfirma SA CIF A82743287:http://www.chambersign.org -> data/ca_000.der
# ... output snipped ...
TrustCor Systems:TrustCor Systems S. de R.L.:TrustCor Certificate Authority -> data/ca_146.der
ar: creating archive data/certs.ar

$ ls
certs-from-mozilla.py* data/

$ ls data
certs.ar

The resulting file, certs.ar, is what you need to flash to your device.

Prerequisite: Flashing the Store to ESP8266 Flash memory

I’ll perform this step using the PlatformIO toolchain, since that’s what I’m using for my experiments. If you’re using the standard Arduino IDE, refer to these instructions provided by the ESP8266 Arduino core documentation.

At the root of your project, create a data folder, and place certs.ar inside. You can see this structure in my example project.

For new projects, you should choose to use LittleFS rather than SPIFFS, as SPIFFS is deprecated. To do that, add this line to platformio.ini:

board_build.filesystem = littlefs

Connect your dev board to your computer via USB, and then from your project’s root directory, run platformio run --target uploadfs. This will upload everything from your data folder to LittleFS in the board’s flash memory. If all goes well, your output should look something like this.

Prerequisite: Initializing the Filesystem & Certificate Store

First of all, at the beginning of the program’s setup function, call LittleFS.begin(). This took me a few minutes to figure out — I skipped over it while reading the example code!

We need to declare a global, shared CertStore, tell it to load certs.ar from flash memory, and finally configure our WiFiClientSecure to use that certificate store.

Putting that all together, it looks like this:

#include <Arduino.h>
#include <ESP8266WiFi.h>
#include <CertStoreBearSSL.h>
#include <FS.h>
#include <LittleFS.h>

BearSSL::CertStore certStore;
WiFiClientSecure wifiClient;

void setup() {
    LittleFS.begin();

    int numCerts = certStore.initCertStore(LittleFS, PSTR("/certs.idx"), PSTR("/certs.ar"));
    Serial.printf_P(PSTR("%lu: read %d CA certs into store\r\n"), millis(), numCerts);
    if (numCerts == 0) {
        Serial.println(F("!!! No certs found. Did you run certs-from-mozilla.py and upload the LittleFS directory?"));
        return;
    }

    wifiClient.setCertStore(&certStore);
}

Making a Request Using the Certificate Store

Once the certificate store is set up to read certs.ar from flash, and your WiFiClientSecure is configured to use the certificate store, the hard work is done. Simply make an HTTPS request as described in “Basic Setup,” above:

#include <ESP8266HTTPClient.h>

    // ...
    HTTPClient httpClient;
    httpClient.begin(wifiClient, "https://ip.dzdz.cz");
    int respCode = httpClient.GET();
    if (respCode >= 400) {
        // HTTP error
    } else if (respCode > 0) {
        String result = httpClient.getString();
        // Do something with result...
    } else {
        // Other error occurred
    }
    httpClient.end();
    // ...

I’ll note that you can reuse that httpClient instance, but you may encounter a bug when reusing it to connect to a different server. See my post, “Reusing an ESP8266 HTTPClient”, for details.

Discussion/Warning: ISRs and Flash

A brief note: certificate verification using a certificate store requires accessing the flash memory. You’ll want to be sure nothing in any of your interrupt service routines touches flash, or you’ll see crashes when the certificate verification process is interrupted.

This sounds obvious, but it’s bitten me: it turns out that on ESP8266, floating-point math can result in flash memory accesses. Read my earlier post [“Debugging an Intermittent Arduino/ESP8266 ISR Crash”](/blog/2021/10/Debugging-an-Intermittent-Arduino-ESP8266-ISR-Crash.html) for details.

Timekeeping

Proper TLS certificate verification (as performed by the certificate store and X509List approaches, described above) requires knowing the current time, since certificates are only valid for a certain time window. Surprisingly, thanks to some timekeeping functionality built into the ESP8266 Arduino core, this turned out to be straightforward.

I started with this implementation, borrowed from some example code buried in the Arduino ESP8266WiFi library:

#include <Arduino.h>
#include <time.h>

// Set time via NTP, as required for x.509 validation
void setClock() {
  configTime(3 * 3600, 0, "pool.ntp.org", "time.nist.gov");
  Serial.print("Waiting for NTP time sync: ");
  time_t now = time(nullptr);
  while (now < 8 * 3600 * 2) {
    delay(500);
    Serial.print(".");
    now = time(nullptr);
  }
  Serial.println("");
  struct tm timeinfo;
  gmtime_r(&now, &timeinfo);
  Serial.print("Current time: ");
  Serial.print(asctime(&timeinfo));
}

This fetches the time from an NTP server asynchronously, waits for that process to complete, and then prints the current time.

I also wanted to figure out how to handle timezones in the Arduino world, so I did a little searching. (This will be useful for another project I have planned.) It turns out that the example code above is hardcoded to a timezone of UTC+3, with no Daylight Saving Time offset. A comment in the configTime function it uses explicitly says, “The other API should be preferred”. This naturally led me to wonder, “what other function?”

That other version of configTime takes a timezone argument, and using that function I came up with this implementation:

#include <Arduino.h>
#include <time.h>
#include <TZ.h>

/**
 * Sets the system time via NTP, as required for x.509 verification
 * see https://github.com/esp8266/Arduino/blob/master/libraries/ESP8266WiFi/examples/BearSSL_CertStore/BearSSL_CertStore.ino
 */
void setClock() {
    #define CFG_TZ TZ_America_Detroit
    configTime(CFG_TZ, "pool.ntp.org", "time.nist.gov");

    Serial.printf_P(PSTR("%lu: Waiting for NTP time sync "), millis());
    time_t now = time(nullptr);
    while (now < 8 * 3600 * 2) {
        delay(250);
        Serial.print(".");
        now = time(nullptr);
    }
    Serial.print(F("\r\n"));
    struct tm timeinfo;
    gmtime_r(&now, &timeinfo);
    Serial.printf_P(PSTR("Current time (UTC):   %s"), asctime(&timeinfo));
    localtime_r(&now, &timeinfo);
    Serial.printf_P(PSTR("Current time (Local): %s"), asctime(&timeinfo));
}

The CFG_TZ definition refers to a zone defined in TZ.h, part of the Arduino core.

So now when this demo starts up, it prints the current time in UTC and in my local timezone. Nice!

Performance Concerns & Solutions

Processor Frequency

The ESP8266 Arduino core documentation notes that the computations involved in a TLS handshake are expensive, particularly on a microcontroller with no hardware acceleration for the operations involved.

The documentation therefore recommends running the ESP8266 at 160MHz, rather than the default 80MHz, for a sketch that uses TLS. According to some forum threads, this can allegedly cause instability or issues with WiFi, but it is officially supported and I haven’t noticed any such issues in my (admittedly brief) testing.

To switch to 160MHz, add the line board_build.f_cpu = 160000000L to your platformio.ini file.

Sessions

The ESP8266 Arduino core supports TLS sessions, which, if supported by the server, can make subsequent HTTPS connections faster. The trade-off, of course, is using a little bit of precious RAM as a cache.

Using this feature is straightforward. Define a BearSSL::Session in the global scope, alongside your WiFiClientSecure:

#include <BearSSLHelpers.h>
BearSSL::Session tlsSession;

Then, tell the WiFiClientSecure to use that session cache:

wifiClient.setSession(&tlsSession);

An example is also provided by the ESP8266 Arduino core.

Limiting Cipher Selection

It is possible to limit cipher selection, which can theoretically be useful if you know ahead of time what cipher(s) your server supports (and will continue supporting). I would not recommend doing this. You’ll likely end up using a less secure configuration, and if the server’s configuration ever has to change, all your ESP8266 clients will break.

And don’t call setCiphersLessSecure(), for obvious reasons — the hint is in the method name.

As the docs say, “there is very rarely reason to use these calls”.

Check Return Codes!

The subset of C++ we’re working with here doesn’t have any advanced error-handling features; checking functions’ return codes for errors is critical.

The codes returned by HTTPClient’s GET() method and friends break down like this:

In my code, I simplify this a bit, by:

  1. First, checking if the return code is equal to or greater than 400 (indicates an HTTP error);
  2. Then, assuming anything above zero is a success;
  3. Finally, passing anything else (eg. library-level errors) to HTTPClient::errorToString(...) and logging the resulting error string.

Limiting TLS Versions

The BearSSL:WiFiClientSecure class allows you to limit the TLS versions your application will use.

TLS 1.0 and 1.1, though supported by BearSSL, contain various security flaws. They are no longer supported by some widely-used client software; for example, Google Chrome removed support for it completely in Chrome 84, which was released in July 2020. Unless you know you need a legacy TLS version, it’s a good idea to use TLS 1.2. Do this with this line:

wifiClient.setSSLVersion(BR_TLS12, BR_TLS12);

Unfortunately, this API doesn’t allow setting only a minimum version requirement. I’d like to see that feature added, since really what I want to do is to say “don’t use anything older than TLS 1.2, but if a server allows using anything newer, do that.” (Go allows doing this; see the MaxVersion field in crypto/tls.Config.) As it stands, this line will preclude using TLS 1.3, once BearSSL supports it.

That seems unlikely to be a practical issue in the near term, though: BearSSL does not currently support TLS 1.3. The project’s TLS 1.3 Status page outlines the plan to support TLS 1.3; it’s ten steps, some of which are quite involved; and I don’t see any indication that anyone has started to work on it.

tl;dr: Limiting your project to TLS 1.2 is the best you can do, and using TLS 1.3 isn’t going to be possible for some time anyway.

Putting it all together

Honestly, reading my sample project’s code is probably easier than reading through this blog post! It walks through setting up the basic WiFi connection, WiFiClientSecure, TLS session, and certificate store; then using an HTTP client to make multiple HTTPS requests. Plus there’s some ArduinoJson usage thrown in for good measure.

And of course, you can always refer to the official documentation on BearSSL::WifiClientSecure.

Appendix: Working with JSON

I had planned to add some notes here on using ArduinoJson, but their website and documentation are stellar. Go read them!