ESP8266 Signed OTA updates from continuous integration
#Coding#MakeThe ESP8266 is an inexpensive microcontroller with Wi-Fi capabilities. It is really popular with makers, because with its great community and integration for many SDKs, it enables them to expose sensor data to the network for cheap. A great variety of boards with this chip in different form factors and with additional features like a USB-controller for programming have been developed and are being sold.
Hacking a demo together with the ESP8266 is easy and fast as the chip is well-supported by the Arduino ecosystem and there is even a MicroPython fork for it. With a large community, many beginner questions will be resolved after a quick search on the internet.
However, making a professional appliance with the ESP8266 is harder. When using the chip in a production like setup, for example soldered into a circuit in a remote place, the board cannot easily be connected to a PC by wire every time the code changes. Furthermore, as the code may be published to some public version control platform, hard coding Wi-Fi credentials is not an option anymore.
This post will describe all the various steps to set up automated and secure updates of the ESP8266 and provide common code snippets that can be reused in ESP8266 projects.
Avoiding hard coded WiFi credentials
Hard coded credentials, like a Wi-Fi password in the firmware, should be avoided. They require the device to be reprogrammed on configuration changes. Additionally, the source code or a binary firmware with hard coded credentials can not be shared easily as sensitive information could be leaked.
Therefore, it is strongly recommended to avoid hard coding credentials or sensitive configuration for the ESP8266.
The ESP8266 then provides several ways to permanently store data in the flash memory, the two most popular ones are EEPROM emulation for storing binary data in the flash memory and based on this multiple APIs for file system like access to the flash memory..
Gathering the credentials at runtime can happen in multiple ways, for example by reading commands over a serial connection. If only the Wi-Fi credentials need to be stored, there is no need to bother with the EEPROM emulation, as the Arduino core for the ESP8266 already has a way to persist the Wi-Fi credentials:
There are also libraries that handle more complex cases. A notable example is the WiFiManager library, that sets up an access point with a web interface to configure the Wi-Fi. It can easily be extended for additional parameters and is as easy to use as
HTTPs on the ESP8266
Now, with a working Wi-Fi connection, let’s talk about communication with servers. The ESP8266 can establish secure connections using HTTPS.
When a request to an encrypted website - using HTTP over TLS (HTTPS) - is made, the web server provides a TLS certificate. This TLS certificate contains a public key and information about the certificate itself, including when it will expire and for which domain name - also called Common Name - it was issued.
The client can then use the certificate to verify the identity of the server. First it has to check that the certificate has not expired yet and whether the common name matches the host the request was made against. But now anyone can create a certificate with any common name and expiry date.
How does the client know which certificate can be trusted?
The client can store a list of trustworthy certificates. But storing the certificate of a server itself is impractical, as these certificates are frequently changed and therefore expire after a short time. For example, Let’s Encrypt certificates expire after 90 days.
Fortunately, certificates are usually signed by other certificates, which may themselves be signed by other certificates again. This list of signatures is called a certificate chain, and the certificate on top of the certificate chain is a long-living, so-called Root certificate.
The owner of the root certificate should be a trusted authority and guarantee,
that all certificates signed by the root certificates can be trusted.
Hence, in order to verify the certificate of a web server, a set of trustworthy
root certificates must be obtained.
In Linux distributions, this set of root certificates is usually
packaged under the
name ca-certificates
.
The Arduino core for the ESP8266 provides a little tool to fetch
the fingerprint of the certificate as well as all public keys in the
certificate chain for a host - including the root certificate - and write them
into a header file.
Let’s assume, a secure request against wttr.in
- a handy tool to
get an ASCII weather report - shall be performed.
First, the certificates are written into a header file with
python3 cert.py -s wttr.in -n wttr_in > cert_wttr_in.h
At the time this post is written, wttr.in uses Let’s Encrypt, so the ESP8266 needs the ISRG Root X1 as trusted root certificate.
Furthermore, the EPS8266 needs the current date to check if the server’s
certificate is still valid.
In ESP8266 core lives an
undocumented function called configTime
to easily set NTP servers.
Now the time is fetched asynchronously in the background.
To avoid race conditions, the program could wait until time(nullptr)
returns a value other than the start of the Unix epoch.
Then a secure request is performed with
OTA Updates
The Arduino core for the ESP8266 already has well documented Over-The-Air update capabilities including signature verification and HTTP “Basic” authentication.
Signing Updates
The Updater
class of the Arduino core for the ESP8266 supports the
verification of a signed cryptographic hash, which is appended to the update.
To sign the update, the binary firmware has to be acquired first. Normally, when uploading a sketch to the board, the binary firmware is stored onto the disk in some temporary folder hidden from the user. The common tools used to build and flash the firmware onto the board still provide ways to export the binary:
- The Arduino cli will export the binary with
arduino-cli compile \ --build-property compiler.cpp.extra_flags='-DMY_FLAG' \ --export-binaries \ --fqbn=esp8266:esp8266:nodemcuv2 \ <my-sketch>.ino
to
build/esp8266.esp8266.nodemcuv2/<my-sketch>.ino.bin
- PlatformIO Core will write binaries to
.pio/build/nodemcuv2/firmware.bin
. - The legacy Arduino IDE (v1.x) has the option to export binaries into the sketch folder.
Now a signed update consists of three parts:
Signing the binary requires an RSA-2048 key pair in PEM format. This pair can be created with OpenSSL using
With the private key and OpenSSL, a signature for any file can be created with
Now the length of the signature can be encoded with a bit of python code, where
<L
is the format string for an unsigned, 4 byte little endian integer:
Finally packing it all together to sign the firmware manually
Now the Arduino core for the ESP8266 already comes with the signing tool to sign a binary without all these manual steps:
To push or to pull?
When doing an OTA update, the ESP8266 can either pull the new code from a server or have the update pushed from another system. Pushing updates implies exposing the ESP to the system deploying the updates. This post covers providing OTA updates from within a Continuous Integration pipeline. There are two problems with pushing updates to an IOT device:
- Generally, CI runners are not hosted at home, but somewhere on the internet. So pushing updates from the CI server to the ESP8266 means exposing the board to the internet. Depending on your network design, this may require serious effort, for example exposing the ESP behind a carrier-grade NAT.
- As you may already know, the S in IOT stands for security, and exposing your (homemade) IOT device over the internet may not be the best idea.
Therefore, letting the board periodically check for updates from a server seems to be the way to go.
Serving OTA updates
The ESP8266 core already provides a library that can query a server for updates. A request from that library to update endpoints looks like
GET /update.php HTTP/1.0
Host: <My-Server>
User-Agent: ESP8266-http-Update
Connection: close
x-ESP8266-Chip-ID: XXXXXXXX
x-ESP8266-STA-MAC: 18:FE:AA:BB:CC:DD
x-ESP8266-AP-MAC: 1A:FE:11:22:33:44
x-ESP8266-free-space: 655360
x-ESP8266-sketch-size: 304544
x-ESP8266-sketch-md5: 24d2538f20eef9120eb1c16f8181951a
x-ESP8266-chip-size: 4194304
x-ESP8266-sdk-version: 2.2.2-dev(38a443e)
x-ESP8266-mode: sketch
x-ESP8266-version: 0.0.1
Content-Length: 0
The server hosting the update can use the x-ESP8266-sketch-md5
and
x-ESP8266-version
headers to determine if any newer firmware is available.
It should be noted, that the x-ESP8266-sketch-md5
header always contains
the hash of the unsigned firmware, even if the ESP8266 was updated with a signed
firmware.
Also, the MAC address can be used to provide different versions to
different boards, for example to deploy multiple configurations.
The server uses the HTTP response code 304
to indicate, that no
newer firmware is available yet.
An example for the server code in PHP, which expects (simple) semantic versioning and provides the update only if either the version hosted on the server is newer than the currently deployed version or if the hashes differ for the same version is
Pulling signed OTA updates over HTTPs
The code to let the ESP8266 pull an update from a server is simple.
First, the ESP8266 needs the public key to verify the signature of the update.
Add a new file ota_key.h
to the sketch, that will hold the key:
First let the ESP8266 connect to a WiFi network and the setup the ESP8266httpUpdate class to query a server periodically for updates.
Define a CD pipeline
With all the previous steps, the ESP8266 is able to fetch signed updates over a secure channel. Now signing the updates by hand, uploading them somewhere and keeping track of version numbers are stupid and repetitive tasks. Let’s set up a pipeline for continuous deployment.
Of course, the actual definition of the pipeline depends on the CI/CD platform that is used. In the following, Woodpecker CI is used, but the details should be similar for most other platforms.
The pipeline will build to code at every commit to verify that the project structure is still intact, and the compiler can make sense out of every change. However, signing and uploading the signed firmware only happens when a tag gets pushed.
CI platforms usually run codes inside Docker images.
Let’s assume there is a Docker image with PlatformIO Core set up
and the framework-arduinoespressif8266
package for PlatformIO installed hosted
at git.kalehmann.de
(the public DNS entry is only disguise, don’t even try).
The configuration for PlatformIO contains
The first step would be to fetch the certificate of the target host, which will
contain OTA updates.
That requires to know the URL where the updates will be placed.
As the updater URL may be different when somebody forks the repository, it will
not be hard coded, but is instead defined as a parameter to the pipeline.
Woodpecker CI calls these parameters “secrets”.
This step uses a secret called ota_url
:
The next step includes defining the public key used for the signature
verification of the firmware, the version of the firmware as well as
the updater URL and building the firmware.
The version of the firmware is read from the environment variable
CI_COMMIT_TAG
that is defined by Woodpecker CI.
The other two values are read from secrets.
Since Woodpecker CI has issues with preserving newlines in secrets,
SIGN_PUBLIC_KEY
contains the key base64 encoded with
base64 --wrap=0 public.key
When building the firmware, PlatformIO stores the binary firmware as
.pio/build/nodemcuv2/firmware.bin
inside the current working directory.
The next step will use another secret SIGN_PRIVATE_KEY
.
That secret contains the base64 encoded private key, that is used to cryptographically
sign the firmware.
Finally, the signed firmware has to be uploaded to the target host.
The next step uses another image hosted on git.kalehmann.de
which has lftp
installed.
As all the information needed to connect to the target host in sensitive, there
are four more secrets ftp_password
, ftp_port
, ftp_server
and ftp_user
introduced:
Conclusion
Most of the snippets provided above contain just boilerplate code, that can be smoothly added and adapted to new or existing projects. Having a continuous and secure roll out of code changes significantly reduces the effort required to roll out code changes and test them on real hardware. Besides keeping secrets and configuration out of the code and the repository facilitates sharing the code.