انتقل إلى المحتوى الرئيسي

HTTPS on a Microcontroller

Everything the device serves (dashboard, REST, WebSocket) goes over HTTPS, from a chip with no operating system and a few hundred kilobytes of RAM. This chapter is how, and what it cost.

Why TLS at all, on a LAN device

The dashboard carries a bearer token and WiFi credentials. On plain HTTP, anyone on the same network reads both with a packet capture; "it is only my LAN" fails at the first guest network or compromised laptop. Encrypting on-device costs one-time integration pain and a per-session memory bill; both are paid below, and the answer to "why bother" is that the alternative is unshippable.

Safi uses esp_https_server (ESP-IDF's httpd wrapped in TLS via mbedTLS). One server, port 443, HTTP/1.1 with keep-alive, WebSocket upgrades on the same port.

The certificate model

Browsers trust certificates chaining to public authorities, and public authorities do not issue for 192.168.1.37. Every LAN device faces the same three options: plain HTTP (rejected above), a certificate per device signed by a private CA the user installs (right for fleets, hostile to hobbyists), or a self-signed certificate with a one-time browser warning. Safi ships the third as default (tools/generate_https_certs.sh makes a 10-year dev cert into littlefs_data/certs/), and supports the second for production via provisioning: per-device certs generated outside the git tree.

The private key lives with the firmware image; the honest threat model is protection of traffic on the wire, not of the key against someone holding the hardware. Flash encryption (see security) is the answer to the latter.

Two integration bugs worth internalizing

Both bugs shipped in the first integration, both produced zero requests served, and both are archetypes.

The null terminator. Certificates were embedded with CMake's EMBED_FILES, which appends file bytes verbatim. mbedTLS's PEM parser requires the buffer to end with a NUL byte and include it in the length, because PEM is text and the parser walks it as a string. Every TLS session died at X509 - The CRT/CRL/CSR format is invalid (error -0x2180) against a certificate that every desktop tool validated happily. The fix is the sibling directive EMBED_TXTFILES, which appends the NUL. The archetype: when a parser rejects input that other tools accept, diff the bytes at the boundary, not the content.

The allocation squeeze. Fixed that, and handshakes then failed with MBEDTLS_ERR_SSL_ALLOC_FAILED: each TLS session wants roughly 40 KB (16 KB input record buffer by spec-required default, output buffer, handshake state), and internal RAM after LVGL did not have it. The fix was CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y, pointing mbedTLS's allocator at PSRAM: TLS buffers are CPU-only data, exactly what PSRAM is for (placement rules). Handshakes cost a few extra milliseconds from slower memory and stopped failing forever. The record buffers are also asymmetric (SSL_IN_CONTENT_LEN 16 KB, SSL_OUT_CONTENT_LEN 4 KB) because the device sends small responses but must accept whatever record size a browser sends.

A third fix belongs to this story even though it lives in the WiFi chapter: modem power save was stretching handshake round trips into a 10-second dashboard. TLS debugging on embedded is usually three problems wearing one symptom.

Serving the dashboard

Static files come from a LittleFS partition (the storage partition, built from littlefs_data/www/ at compile time). Two details:

  • The wildcard route /* uses httpd's uri_match_wildcard; API routes are registered first and win on exact match. (The default matcher is exact-only, and its absence is invisible until the first static request 404s.)
  • Responses stream in chunks straight from flash-backed files; no file is ever fully buffered. LittleFS over FAT for this partition because it is power-cut safe by design and the dashboard is written once per firmware flash. Content lives uncompressed; at 170 KB total, gzip would save little against the complexity of on-device negotiation.

The memory budget, summarized

ConsumerPlacementSize
TLS session buffers (per connection)PSRAM~40 KB
httpd task stackinternal10 KB
Session bookkeeping, socketsinternalfew KB each
Certificatesflash (embedded)~3 KB

Concurrent sessions are capped by httpd's socket limit; each open dashboard tab holds one keep-alive session plus the WebSocket. The practical ceiling on this hardware is a handful of simultaneous clients, which for a household appliance is the right ceiling at the right price.