From 238aeb5de0d8fc6b7126bfdf03d6022d36358b41 Mon Sep 17 00:00:00 2001 From: 2pk03 Date: Mon, 12 Oct 2026 16:38:47 +0200 Subject: [PATCH] Update small-device TLS guide for core #52 --- guides/small-devices-tls.md | 45 +++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/guides/small-devices-tls.md b/guides/small-devices-tls.md index 6b6f2c1..96bd1d4 100644 --- a/guides/small-devices-tls.md +++ b/guides/small-devices-tls.md @@ -58,16 +58,22 @@ With an ECDSA server certificate the device verifies P-256 signatures instead of - For a private deployment, a private CA with one P-256 root and the server certificate signed directly by it gives a chain of one certificate. - Pin the CA on the device (one trust anchor) instead of loading a big CA bundle. -Today the infinimesh MQTT bridge loads one certificate from `TLS_CERT_FILE`, and the local test certificate in core (`hack/server.crt`) is RSA 2048. The bridge accepts an ECDSA certificate if you give it one, because Go's TLS stack supports ECDSA. Pointing `TLS_CERT_FILE` and `TLS_KEY_FILE` at a P-256 certificate is enough. Offering RSA and ECDSA side by side needs a core change, see the list at the end. +The MQTT bridge can serve an ECDSA certificate next to the primary one. Set `TLS_EC_CERT_FILE` and `TLS_EC_KEY_FILE` (both or neither). Each client gets the certificate its cipher suites allow, so a TLS 1.2 client that offers only ECDSA suites gets the ECDSA certificate. The bridge requires TLS 1.2 or newer. -Checked against core main on 12 October 2026: a TLS 1.2 client gets `ECDHE-RSA-AES128-GCM-SHA256` with the default RSA certificate. +```bash +TLS_EC_CERT_FILE=/cert/tls-ec.crt +TLS_EC_KEY_FILE=/cert/tls-ec.key +TLS_HANDSHAKE_TIMEOUT=45s +``` + +For local tests, core ships a P-256 pair in `hack/server-ec.crt` and `hack/server-ec.key`, with subject alternative names for `localhost` and `127.0.0.1`. Never serve that pair next to a public certificate, or a client that prefers ECDSA gets a certificate for `localhost`. The details are in core's [small-device TLS guide](https://github.com/infinimesh/core/blob/main/docs/guides/small-devices-tls.md). ### 3. Give the handshake enough time The timeout can come from three places: - The device. On the ESP32, `WiFiClientSecure::setHandshakeTimeout(seconds)` and ESP-MQTT `network.timeout_ms`. On the ESP8266, `client.setTimeout(ms)`. -- The MQTT bridge. In core, `cmd/mqtt-bridge/mqtt-bridge.go` gives each TLS handshake a fixed 30 seconds. It cannot be changed without a code change. After the handshake the bridge waits for the CONNECT packet. +- The MQTT bridge. `TLS_HANDSHAKE_TIMEOUT` sets how long the bridge waits for the handshake. It is a Go duration such as `30s` or `1m`, and the default is `30s`. It covers the handshake only. After the handshake the bridge waits for the CONNECT packet. - A proxy in front of the bridge. In the core compose files port 8883 goes straight to the bridge, Traefik and Caddy do not sit in front of it, so their timeouts do not apply. If you put a TCP proxy in front yourself (for example a Traefik TCP router or HAProxy), check its read and idle timeouts. In Traefik these are the entry point `transport.respondingTimeouts` settings ([Traefik entry points](https://doc.traefik.io/traefik/routing/entrypoints/#respondingtimeouts)). If the handshake takes more than 30 seconds even with P-256 keys, raising the timeout only hides the problem. Fix the keys first. @@ -76,7 +82,7 @@ If the handshake takes more than 30 seconds even with P-256 keys, raising the ti A full handshake is only needed once. After that the device can resume the TLS session and skip the public key math. The ESP8266 docs say this saves 3 to 4 seconds with EC keys ([ESP8266 BearSSL docs](https://arduino-esp8266.readthedocs.io/en/latest/esp8266wifi/bearssl-client-secure-class.html), see `BearSSL::Session`). -TLS 1.2 has two ways to resume: session IDs (server keeps a cache) and session tickets ([RFC 5077](https://www.rfc-editor.org/rfc/rfc5077)). Go servers resume only with tickets. We checked core main: OpenSSL resumes a ticket session against port 8883. Whether a given client library sends tickets or only session IDs decides if resumption works. We have not tested the ESP8266 session API against the bridge yet. +TLS 1.2 has two ways to resume: session IDs (server keeps a cache) and session tickets ([RFC 5077](https://www.rfc-editor.org/rfc/rfc5077)). Go servers resume only with tickets. A resumed session still identifies the device by its client certificate, and core has a test for this on TLS 1.2 and TLS 1.3. Tickets last 7 days. The ticket keys live in the bridge process, so a bridge restart, or a connection that lands on another replica, falls back to a full handshake. Whether a given client library sends tickets or only session IDs decides if resumption works. We have not tested the ESP8266 session API against the bridge yet. Keep the MQTT connection open with a keepalive when you can. One long connection beats many short handshakes. @@ -102,13 +108,23 @@ if (net.probeMaxFragmentLength(host, 8883, 1024)) { Run the CPU at 160 MHz. Keep the objects global, BearSSL does not copy them. -Maximum Fragment Length Negotiation (MFLN, [RFC 6066 section 4](https://www.rfc-editor.org/rfc/rfc6066#section-4)) lets the client ask for records of 512 to 4096 bytes. **The infinimesh MQTT bridge does not support MFLN today**: Go's TLS stack does not implement it, and our test against core main got no MFLN reply. So on the ESP8266 the receive buffer must stay at 16 KB, unless you are sure the bridge never sends a record bigger than your buffer. During the handshake the bridge sends its certificate chain, so a short chain matters here too. +Maximum Fragment Length Negotiation (MFLN, [RFC 6066 section 4](https://www.rfc-editor.org/rfc/rfc6066#section-4)) lets the client ask for records of 512 to 4096 bytes. **The infinimesh MQTT bridge does not support MFLN**: Go's TLS stack does not implement it, and core's guide confirms the bridge ignores the extension. So on the ESP8266 the receive buffer must stay at 16 KB, unless you are sure the bridge never sends a record bigger than your buffer. During the handshake the bridge sends its certificate chain, so a short chain matters here too. ### 6. TLS-PSK TLS with a pre-shared key ([RFC 4279](https://www.rfc-editor.org/rfc/rfc4279), [RFC 8442](https://www.rfc-editor.org/rfc/rfc8442) for `ECDHE-PSK-AES128-GCM-SHA256`) skips certificates completely. Pure PSK needs no public key math at all, so it fits the smallest chips. -**The infinimesh MQTT bridge does not support PSK.** Go's `crypto/tls` has no PSK cipher suites ([golang/go#6379](https://github.com/golang/go/issues/6379)), and a PSK handshake against core main fails with a handshake failure alert. Supporting it needs a TLS terminator that speaks PSK in front of the bridge, see the list at the end. +**The infinimesh MQTT bridge itself does not support PSK.** Go's `crypto/tls` has no PSK cipher suites ([golang/go#6379](https://github.com/golang/go/issues/6379)). + +You can run stunnel in front of the bridge. Core ships a sample in `hack/stunnel/psk.conf`. The device talks TLS-PSK to stunnel on `127.0.0.1:8884`, and stunnel opens a TLS connection to the bridge on 8883 with the client certificate registered for that device. The bridge still requires and checks the certificate. One stunnel service is one device, so this does not scale to a large fleet. + +Generate your own key and never commit the secrets file: + +```bash +openssl rand -hex 16 +``` + +The secrets file has one line, `identity:hexkey`. See core's [small-device TLS guide](https://github.com/infinimesh/core/blob/main/docs/guides/small-devices-tls.md) for the full setup. Note that BearSSL does not support PSK suites either, so on the ESP8266 this would need mbedTLS or WolfSSL. @@ -133,17 +149,18 @@ This is also the answer for boards with no TLS at all, like the ATmega based Ard - [ ] TLS 1.2, `ECDHE-ECDSA-AES128-GCM-SHA256`. - [ ] Clock is set with NTP before connecting. - [ ] Device handshake timeout is 20 to 30 seconds. +- [ ] Bridge timeout raised with `TLS_HANDSHAKE_TIMEOUT` only if keys are already P-256. +- [ ] Bridge has `TLS_EC_CERT_FILE` and `TLS_EC_KEY_FILE` set. - [ ] ESP8266 at 160 MHz with BearSSL. - [ ] Keepalive instead of reconnecting for every message. - [ ] Still failing: secure element, or a local gateway. -## For Grok Code in core +## Server side status + +Core added the configurable handshake timeout, the ECDSA certificate pair, TLS 1.2 as the minimum, the P-256 test certificate, the session resumption test and the stunnel PSK sample in [core #52](https://github.com/infinimesh/core/pull/52). -Server side changes that would help small devices (checked on core main, 12 October 2026): +Still open in core: -1. `cmd/mqtt-bridge/mqtt-bridge.go`: the TLS handshake timeout is hard coded to 30 seconds. Make it configurable with an env var (for example `TLS_HANDSHAKE_TIMEOUT`), keep 30s as the default, and document it. -2. Same file, `tls.Config`: allow an ECDSA certificate next to the RSA one (two entries in `Certificates`, Go picks by the client's signature algorithms), for example `TLS_EC_CERT_FILE` and `TLS_EC_KEY_FILE`. Set `MinVersion: tls.VersionTLS12` explicitly. Ship a P-256 test certificate in `hack/` next to the RSA one, with a SAN for localhost (the current `hack/server.crt` has only a CN, so strict clients reject it). -3. Session resumption: confirm that resumed TLS sessions still identify the device (the bridge reads `PeerCertificates` after the handshake), add a test for it, and document ticket lifetime. Consider a server side session ID cache for clients that cannot use tickets. Go has none, so this would need a terminator. -4. TLS-PSK: Go cannot do it. Option: an optional TLS-PSK terminator (for example stunnel) in front of the bridge that maps a PSK identity to a device and passes the device id to the bridge. Treat as a design task, not a quick fix. -5. MFLN: Go does not implement it. Document that the bridge does not negotiate it, and keep the server chain short so a small receive buffer has the best chance. -6. Docs: add a "small devices" section to `docs/device-auth.md` that links to this guide. +1. Port 1883 is plaintext and published by default in the compose file. Make it opt-in or add a clear warning. +2. `TLS_HANDSHAKE_TIMEOUT` has no upper limit. A cap would limit slow handshake abuse. +3. Ignore `hack/stunnel/psk-secrets.txt` in `.gitignore` so a real key is not committed by accident.