Skip to content

Commit 9f0dbf8

Browse files
lxsaahclaude
andcommitted
docs(design): add 044 — Embassy MQTT client TLS
Resolves 042 §8.1 option 1: embedded-tls 1.3 over the Embassy TCP socket with rustpki certificate verification, injected TRNG entropy, an SNTP time source, DNS resolution, and broker authentication — plus the gamma wiring and the open questions (CA distribution, record-size negotiation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 213c6be commit 9f0dbf8

1 file changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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

Comments
 (0)