Remote access to an ESP32 without TLS
The same firmware needed 23.6 KB of RAM to stay connected over TLS and 9.4 KB over the Noise protocol. This is what was measured, what had to be written, and what the choice costs.
The problem
A device that has left your desk is on a network you cannot reach. To open its web page, read its log or send it new firmware, it has to keep a connection open to a server you can reach. On an ESP32 that connection is nearly always TLS, and TLS is the most expensive thing most firmware does with memory.
What TLS costs
The first version of this component used a WebSocket over TLS with mbedTLS, as nearly everything on the ESP32 does. On an ESP32-S2 with ESP-IDF 5.5.3, measured as the free heap of the same application built with and without the component:
| Over TLS | |
|---|---|
| RAM while connected and idle | 23,632 bytes |
| Further dip under traffic | about 6 KB |
| Flash added to the application | about 155 KB more than without TLS |
That was with CONFIG_MBEDTLS_DYNAMIC_BUFFER, which frees the handshake buffers once the session is up. Without it the figure is higher: Espressif's own estimate for one TLS connection is about 35 KB. On a chip where the application already has perhaps 100 KB of heap to live in, a quarter of it goes to staying reachable.
What the connection actually has to do
TLS solves a general problem: any client, any server, trust decided by a chain of certificates. A device that only ever talks to one server, whose address is compiled into it, has a much smaller problem:
- the device must know it reached the real server;
- its credentials must never travel in the clear;
- nobody on the path may read, change, replay or reorder what is sent.
None of that needs certificates, a negotiation of cipher suites or a 16 KB record buffer. It needs one public key that the device already knows.
Noise, in two messages
The Noise protocol framework is what WireGuard's handshake is built on. The pattern used here is Noise_NK_25519_ChaChaPoly_SHA256: the server has a static key that the device knows in advance, the device has none.
device -> server "PSL1" | e (32 bytes) | tag (16) -> e, es server -> device e (32 bytes) | tag (16) <- e, ee
After those two messages each direction has its own ChaCha20-Poly1305 key and a counter. Every message from then on is a two-byte length, the encrypted frame and a 16-byte tag. Only the holder of the server's private key can finish the handshake, so the device knows who it is talking to. The keys exist only for this connection, so recording the traffic and obtaining the server's key later reveals nothing. The device's project key and credential go in the first encrypted frame.
The whole exchange is 100 bytes on the wire and about 0.3 s of CPU on the ESP32-S2.
What had to be written
Dropping TLS means dropping the library that came with it, so the component carries its own X25519, ChaCha20-Poly1305 and SHA-256, written for size and checked against a reference implementation with the same inputs. A few other things turned out to cost more than expected:
- The name lookup.
getaddrinfomakes lwIP keep a resolver cache of about 1.2 KB for good. The component sends one DNS query of its own instead. - Frame sizes. What the server sends to the device is at most 512 bytes a frame, since requests and firmware chunks are small; what the device sends, the slow direction, is 1 KiB. Both buffers are static.
- The connection to the device's own web server. An lwIP socket keeps at most six received packets and drops the rest, so a web server that answers in many small pieces stalled for 1.5 s at a time. The component reaches local servers through lwIP's callback API instead, where the TCP window is the only limit, and needs nothing changed in the project's configuration.
The result
| Bytes | |
|---|---|
| Frame buffers (1 KiB out, 512 B in) and the queue for logs and metrics (2 KiB) | 3,625 |
| Rest of the state, and code placed in IRAM | 1,343 |
| Task stack | 3,072 |
| Task, lock and the socket | 1,544 |
| Total while connected | 9,584 |
The component is 18 KB of flash. Through the tunnel a 64 KB download runs at about 85 KB/s, against 77 KB/s over TLS on the same board, and a small request takes 25 to 40 ms. Nothing is configurable: every buffer has a fixed size, so the figure is the same in every project.
What it costs
This is not a free win, and it is not the right choice for everything.
- Firewalls that inspect traffic. The device connects to port 443, so it passes networks that only let HTTPS out. A firewall that looks inside and allows nothing but real TLS on that port will block it.
- The key is in the firmware. The server's public key is compiled in. Changing the server's key means new firmware for every device, so the key has to be kept safe and kept the same.
- It has not been audited. The primitives are standard and the implementation is tested against a reference, but nobody outside has reviewed it. The source is public so that you can.
- One chip so far. All of the numbers are from an ESP32-S2. The component builds for the ESP32, S3, C3 and C6 on ESP-IDF 5.5 and 6.1; those chips have not been run yet.
The protocol is written down in full in the repository, and the documentation has the same memory breakdown.
Try it
Three devices are free with every feature, and no card is needed. Create an account, or read the five-minute guide first.
More
- Reaching an ESP32 web server from outside its network
- Updating ESP32 firmware over the internet, with rollback
- Crash backtraces from devices in the field
- Remote logging: ESP_LOG without a serial cable
- Heap and stack monitoring in the field
- An ESP32 behind CGNAT, 4G or Starlink
- TLS memory usage on the ESP32, and how to reduce it