|
| 1 | +# 044 — Embassy MQTT client: TLS |
| 2 | + |
| 3 | +**Status:** 🚧 Implemented on `feat/embassy-mqtt-tls` (hardware verification pending) |
| 4 | + |
| 5 | +**Scope:** the `embassy-tls` feature of |
| 6 | +[`aimdb-mqtt-connector`](../../aimdb-mqtt-connector) — TLS, broker |
| 7 | +authentication, DNS resolution, and an SNTP time source for the Embassy MQTT |
| 8 | +client — plus the `weather-station-gamma` wiring that consumes it. Resolves |
| 9 | +the open problem in [042 §8.1](./042-public-weather-mesh-flagship.md) |
| 10 | +(option 1, recommended there) and issue `000-wp7-embassy-mqtt-tls`. |
| 11 | + |
| 12 | +**Consumers:** `weather-station-gamma` |
| 13 | +([`examples/weather-mesh-demo`](../../examples/weather-mesh-demo)) today; |
| 14 | +the MCU station template (issue 006, `aimdb-weather-mesh` repo) once it |
| 15 | +tracks this. The hub/cloud path already has TLS via the Tokio client |
| 16 | +(`rumqttc`, `use-native-tls`) and is untouched. |
| 17 | + |
| 18 | +--- |
| 19 | + |
| 20 | +## 1. Context |
| 21 | + |
| 22 | +EMQX Cloud serverless — the flagship mesh broker (042 D4) — is TLS-only. |
| 23 | +The Embassy MQTT client ([`embassy_client.rs`](../../aimdb-mqtt-connector/src/embassy_client.rs)) |
| 24 | +speaks plain TCP to an IPv4 literal, unauthenticated. Until it can do |
| 25 | +`mqtts://` + username/password, an MCU cannot reach the public mesh at all: |
| 26 | +gamma's `build.rs` deliberately rejects `mqtts://` profiles, and the |
| 27 | +`MQTT_USERNAME`/`MQTT_PASSWORD` consts its `MESH_CONFIG` embedding generates |
| 28 | +are dead code. |
| 29 | + |
| 30 | +TLS on a Cortex-M without an OS means three problems the desktop client gets |
| 31 | +for free: |
| 32 | + |
| 33 | +- **The TLS session itself** — record layer, handshake, certificate |
| 34 | + verification, all `no_std`. |
| 35 | +- **Entropy** — key exchange needs a CSPRNG; the STM32H5 has an on-chip |
| 36 | + TRNG (gamma already binds its `rng` interrupt for the network-stack seed). |
| 37 | +- **Wall-clock time** — certificate validity checking needs the current |
| 38 | + Unix time; the reference board has no RTC battery, so time must come from |
| 39 | + the network (SNTP) after DHCP and *before* the first handshake. |
| 40 | + |
| 41 | +Additionally the broker is now a **hostname**, not an IP (serverless |
| 42 | +deployments get a DNS name, and SNI is mandatory on shared ingress), so the |
| 43 | +client also needs DNS resolution. |
| 44 | + |
| 45 | +## 2. Decisions |
| 46 | + |
| 47 | +| # | Decision | Rationale | |
| 48 | +|---|---|---| |
| 49 | +| D1 | TLS via **`embedded-tls` 0.19** (TLS 1.3 only), behind a new `embassy-tls` cargo feature. | The only maintained pure-Rust `no_std` TLS 1.3 client; its `embedded-io-async` 0.7 traits match our embassy-net pin exactly, so the session wraps the existing `TcpSocket` and slots under mountain-mqtt's `ConnectionEmbedded` unchanged. TLS 1.3-only is acceptable: EMQX supports it, and this client exists for the flagship path. | |
| 50 | +| D2 | Certificate verification via embedded-tls's **`rustpki`** verifier (pure Rust: `der` + `p256`, plus `rsa` and `p384` enabled), against a **root CA the firmware embeds** (DER, supplied by the application). | The `webpki` alternative drags `ring` (C, painful on thumbv8m). `rsa`+`p384` cost flash but make public CA chains (e.g. Let's Encrypt, both its RSA and ECDSA/P-384 intermediates) verify out of the box — 2 MB parts don't care. The station profile (043 §4) carries no CA today, so distribution is the firmware's job; see §9. | |
| 51 | +| D3 | Entropy is **injected**: `TlsOptions.rng: &'static mut dyn CryptoRngCore` (rand_core 0.6). | The connector cannot depend on `embassy-stm32` (it is chip-agnostic); the application owns the TRNG. `embassy_stm32::rng::Rng` implements the trait directly, and `&mut dyn CryptoRngCore` satisfies embedded-tls's `CryptoRngCore` bound via rand_core's blanket impls. | |
| 52 | +| D4 | Time via a **connector-internal SNTP task** (embassy-net UDP), spawned automatically with the TLS manager; a global monotonic-anchored Unix offset backs the `TlsClock` impl. | The board has no RTC; SNTP is 48 bytes of UDP. Making the connector spawn it keeps "TLS just works" true for consumers — no extra app wiring, no way to forget it. The offset is an `AtomicU32` anchored to `embassy_time::Instant`, so one sync serves all future handshakes; the task re-syncs hourly to bound drift. | |
| 53 | +| D5 | The TLS handshake **waits for time sync** before connecting. | `rustpki` skips validity checking when the clock returns `None` — silently accepting expired/not-yet-valid certs on cold boot is worse than a delayed connect. Startup order becomes DHCP → SNTP → TLS, each logged over defmt. | |
| 54 | +| D6 | The TLS path runs a **connector-local port of mountain-mqtt-embassy's manager loop** (`run_tls`); the plain-TCP path keeps calling upstream `run()` unchanged. | Upstream `run()` constructs a bare `TcpSocket` internally — there is no seam to insert a TLS session. Porting the ~100-line loop into `aimdb-mqtt-connector` (reusing the fork's public `Settings`, `MqttEvent`, `ClientNoQueue`, `ConnectionEmbedded`) keeps the fork delta minimal and honours the WP7 acceptance criterion that the local plain-TCP demo path is untouched. | |
| 55 | +| D7 | Broker **hostname resolution via embassy-net DNS**, per connection attempt, on the TLS path only. SNI and hostname verification use the URL host; `mqtts://` with an **IP literal is rejected at `build()`**. | Serverless brokers are DNS names and records can change between reconnects. An IP-literal `mqtts://` URL can never pass `rustpki`'s hostname matching (it compares DNS names; with no name, only a cert with *no* names at all would match) — failing loudly at build beats a forever-retrying handshake on a headless board. The plain path stays IPv4-literal-only: unchanged is the contract. | |
| 56 | +| D8 | Authentication is **orthogonal to TLS**: `MqttConnectorBuilder::with_credentials(username, password)` feeds MQTT CONNECT on both paths. The fork gains upstream 0.4's `ConnectionSettings::with_auth`/`authenticated` constructors — nothing else. | The struct always had the fields; only constructors were missing at our 0.2 fork point. Mirroring upstream's exact API keeps an eventual fork retirement mechanical. Credentials over plain TCP transit cleartext — acceptable on the local demo LAN, and the flagship profile is `mqtts://`. | |
| 57 | +| D9 | **URL scheme selects the transport**: `mqtt://` (default port 1883, plain) vs `mqtts://` (default 8883, TLS). `mqtts://` without `with_tls(...)` is a build error, as is `with_tls` on `mqtt://`. | Same convention as the Tokio client and every MQTT tool; the profile's `broker.url` flows through unmodified. Failing loudly at `build()` beats a connect-time surprise on a headless board. | |
| 58 | +| D10 | TLS record buffers are **application-provided** (`&'static mut [u8]` in `TlsOptions`; 16 640 read / 4 096 write recommended). | A 16 KB read buffer is the price of correctness (a TLS 1.3 peer may send full-size records regardless of our `max_fragment_length` offer). Hiding it inside the connector would bloat every embassy-mqtt user and take sizing away from the one party that knows the board. | |
| 59 | + |
| 60 | +## 3. Non-goals |
| 61 | + |
| 62 | +- **Mutual TLS / client certificates** and **PSK** — the flagship |
| 63 | + authenticates with per-slot username/password (042 §6); embedded-tls |
| 64 | + supports both if a deployment ever needs them. |
| 65 | +- **Session resumption / early data** — reconnects redo the full handshake; |
| 66 | + at weather cadence that is fine. |
| 67 | +- **TLS or DNS for the plain-TCP path** — unchanged by design (D6, D7). |
| 68 | +- **Tokio client changes** — `mqtts://` already works there. |
| 69 | +- **Removing gamma's/the template's `mqtt://`-only guard sight unseen** — |
| 70 | + gamma's guard is lifted here because the same commit wires the TLS it |
| 71 | + guards against; the *template's* guard (WP7 scope item 3) falls only after |
| 72 | + issue 006 lands and an end-to-end `mqtts://` profile run passes on |
| 73 | + hardware. |
| 74 | + |
| 75 | +## 4. Connector API |
| 76 | + |
| 77 | +```rust |
| 78 | +use aimdb_mqtt_connector::embassy_client::{MqttConnectorBuilder, TlsOptions}; |
| 79 | + |
| 80 | +static TLS_READ_BUF: StaticCell<[u8; 16_640]> = StaticCell::new(); |
| 81 | +static TLS_WRITE_BUF: StaticCell<[u8; 4_096]> = StaticCell::new(); |
| 82 | +static TLS_RNG: StaticCell<Rng<'static, peripherals::RNG>> = StaticCell::new(); |
| 83 | + |
| 84 | +let mqtt = MqttConnectorBuilder::new("mqtts://xxxx.eu-central-1.emqx.cloud:8883", stack) |
| 85 | + .with_client_id("station-17") |
| 86 | + .with_credentials(MQTT_USERNAME, MQTT_PASSWORD) |
| 87 | + .with_tls(TlsOptions::new( |
| 88 | + TLS_RNG.init(rng), // &'static mut dyn CryptoRngCore (D3) |
| 89 | + CA_DER, // &'static [u8], root CA, DER (D2) |
| 90 | + TLS_READ_BUF.init([0; 16_640]), |
| 91 | + TLS_WRITE_BUF.init([0; 4_096]), |
| 92 | + )); |
| 93 | +``` |
| 94 | + |
| 95 | +`TlsOptions::new` defaults the SNTP server to `pool.ntp.org`; |
| 96 | +`.with_sntp_server(host)` overrides it (D4). The builder keeps its existing |
| 97 | +shape — `ConnectorBuilder::build()` decides plain vs TLS from the URL scheme |
| 98 | +(D9) and returns the same `Vec` of runner futures, now including the SNTP |
| 99 | +task when TLS is on. |
| 100 | + |
| 101 | +## 5. The TLS manager loop (`run_tls`) |
| 102 | + |
| 103 | +Per connection attempt, in order, every step logged and every failure |
| 104 | +falling through to the reconnect delay (same policy as upstream `run()`): |
| 105 | + |
| 106 | +1. **Wait for time** — until `sntp::unix_now()` is `Some` (D5; first boot |
| 107 | + only, the offset persists across reconnects). |
| 108 | +2. **Resolve** the broker host (`dns_query`, A record) (D7). |
| 109 | +3. **TCP connect** on a socket borrowed from the same 4 KB rx/tx buffers the |
| 110 | + plain path sizes. |
| 111 | +4. **TLS open** — `TlsConnection::new(socket, read_buf, write_buf)` + |
| 112 | + `open()` with SNI = URL host, cipher suite `TLS_AES_128_GCM_SHA256`, and |
| 113 | + a `CryptoProvider` combining the injected RNG with |
| 114 | + `rustpki::CertVerifier<Aes128GcmSha256, SntpClock, 4096>` over the |
| 115 | + embedded CA (D2, D3). |
| 116 | +5. **MQTT session** — `ConnectionEmbedded::new(tls_connection)` into |
| 117 | + `ClientNoQueue`, then the ported loop: CONNECT with credentials (D8), |
| 118 | + re-subscribe the inbound routes (per session, so inbound routing survives |
| 119 | + reconnects — the plain path queues subscriptions only once at startup), |
| 120 | + then ping/poll/action-drain with the same `Settings` timeouts, |
| 121 | + `MqttEvent`s, and pending-action retry semantics as upstream. |
| 122 | + |
| 123 | +The buffers and RNG are `&'static mut` reborrowed each iteration — attempts |
| 124 | +are strictly sequential, so this satisfies the borrow checker without |
| 125 | +copies. The whole task is boxed through the adapter's force-`Send` |
| 126 | +`into_box_future`, under the same single-core-executor invariant as the rest |
| 127 | +of the connector. |
| 128 | + |
| 129 | +## 6. SNTP (`sntp` module) |
| 130 | + |
| 131 | +Minimal SNTPv4 client (RFC 4330 subset), `embassy-tls`-gated: |
| 132 | + |
| 133 | +- one UDP socket, one 48-byte request (`LI=0, VN=4, Mode=client`), transmit |
| 134 | + timestamp taken from the reply, sanity-checked (`Mode=server`, |
| 135 | + `stratum 1..=15`, non-zero timestamp); |
| 136 | +- global state is a single `AtomicU32` — Unix seconds minus |
| 137 | + `embassy_time::Instant::now().as_secs()` at sync — read by |
| 138 | + `unix_now() -> Option<u64>` and the `SntpClock: TlsClock` impl; |
| 139 | +- the task retries every 10 s until the first sync, then re-syncs hourly; |
| 140 | + the server hostname resolves through the same DNS as D7. |
| 141 | + |
| 142 | +The `u32` offset and second resolution are deliberate: certificate validity |
| 143 | +has day granularity, and the representation is unambiguous until 2106. |
| 144 | +(The NTP-era rollover in 2036 is a documented limitation of the on-wire |
| 145 | +format shared by every SNTP consumer; a future era-aware fix is contained in |
| 146 | +one parse function.) |
| 147 | + |
| 148 | +## 7. Gamma wiring |
| 149 | + |
| 150 | +- **`build.rs`**: the `mqtt://`-only panic is replaced by real `mqtts://` |
| 151 | + handling — default port 8883, `cargo:rustc-cfg=mesh_tls`, and CA embedding: |
| 152 | + `MESH_CA` (env var, path to the root CA in PEM or DER) is decoded to DER in |
| 153 | + `OUT_DIR/ca.der` and surfaced as `MQTT_CA_DER`. A `mqtts://` profile |
| 154 | + without `MESH_CA` is a build error with instructions. Plain profiles and |
| 155 | + the no-profile local demo generate byte-identical config to today. |
| 156 | +- **`main.rs`**: TLS statics (RNG cell, record buffers) and the |
| 157 | + `.with_credentials(...)`/`.with_tls(...)` calls sit behind |
| 158 | + `#[cfg(mesh_tls)]`, so a plain-demo build carries zero TLS code or RAM — |
| 159 | + the same compile-time sim-to-real philosophy as design 041. The TRNG |
| 160 | + moves into a `StaticCell` so the one instance seeds the net stack *and* |
| 161 | + feeds TLS. `StackResources` grows 3 → 5 (DNS + SNTP sockets). |
| 162 | + |
| 163 | +## 8. Cost |
| 164 | + |
| 165 | +On the reference STM32H563ZI (640 KB RAM / 2 MB flash), the TLS build adds |
| 166 | +~21 KB of statically-allocated RAM (16 640 + 4 096 record buffers, D10) plus |
| 167 | +the handshake's stack transient, and roughly 150–250 KB of flash |
| 168 | +(p256 + rsa + p384 + SHA-2 + embedded-tls). Comfortable here; a smaller |
| 169 | +part could drop `rsa`/`p384` (features are additive pass-throughs) and pin |
| 170 | +its broker CA chain to P-256. |
| 171 | + |
| 172 | +## 9. Open questions |
| 173 | + |
| 174 | +- **CA distribution.** The firmware embeds the root CA (D2), but the station |
| 175 | + profile doesn't carry one — joining the flagship from an MCU needs the CA |
| 176 | + file fetched out of band (`MESH_CA=isrg-root-x1.pem`). Proposal for a 043 |
| 177 | + revision: optional `broker.ca` (PEM) in the profile, emitted by the |
| 178 | + provisioning service; gamma's `build.rs` would prefer it over `MESH_CA`. |
| 179 | + Belongs to WP2/WP7 follow-up once the EMQX tier (issue 007) fixes the |
| 180 | + actual chain. |
| 181 | +- **Record-size negotiation.** embedded-tls offers `max_fragment_length`, |
| 182 | + but RFC 8449 `record_size_limit` is what modern stacks respect; if EMQX |
| 183 | + honours either, the 16 KB read buffer could shrink substantially. Measure |
| 184 | + against the real broker before optimising. |
| 185 | + |
| 186 | +## 10. Verification |
| 187 | + |
| 188 | +- Host: existing `make check`/`make test` legs, plus unit tests for the URL |
| 189 | + scheme/port/TLS-option validation and the SNTP packet codec. |
| 190 | +- Cross: the existing thumbv7em clippy leg gains an |
| 191 | + `embassy-runtime,embassy-tls,defmt` variant; gamma builds for |
| 192 | + thumbv8m.main with a synthetic `mqtts://` profile + `MESH_CA` (compile |
| 193 | + proof of the full wiring) and without one (plain demo unchanged). |
| 194 | +- Hardware (the WP7 acceptance): gamma + real mesh profile connects, |
| 195 | + authenticates, publishes through the TLS broker — runs on the bench once |
| 196 | + a broker credential exists (issue 007), tracked in the WP7 issue. |
0 commit comments