From 2293eecd85daf94196cd826be5bff431ce63f21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 10 Jul 2026 17:25:04 +0000 Subject: [PATCH 01/11] =?UTF-8?q?docs(design):=20add=20044=20=E2=80=94=20E?= =?UTF-8?q?mbassy=20MQTT=20client=20TLS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/design/044-embassy-mqtt-tls.md | 196 ++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/design/044-embassy-mqtt-tls.md diff --git a/docs/design/044-embassy-mqtt-tls.md b/docs/design/044-embassy-mqtt-tls.md new file mode 100644 index 0000000..942b7d8 --- /dev/null +++ b/docs/design/044-embassy-mqtt-tls.md @@ -0,0 +1,196 @@ +# 044 β€” Embassy MQTT client: TLS + +**Status:** 🚧 Implemented on `feat/embassy-mqtt-tls` (hardware verification pending) + +**Scope:** the `embassy-tls` feature of +[`aimdb-mqtt-connector`](../../aimdb-mqtt-connector) β€” TLS, broker +authentication, DNS resolution, and an SNTP time source for the Embassy MQTT +client β€” plus the `weather-station-gamma` wiring that consumes it. Resolves +the open problem in [042 Β§8.1](./042-public-weather-mesh-flagship.md) +(option 1, recommended there) and issue `000-wp7-embassy-mqtt-tls`. + +**Consumers:** `weather-station-gamma` +([`examples/weather-mesh-demo`](../../examples/weather-mesh-demo)) today; +the MCU station template (issue 006, `aimdb-weather-mesh` repo) once it +tracks this. The hub/cloud path already has TLS via the Tokio client +(`rumqttc`, `use-native-tls`) and is untouched. + +--- + +## 1. Context + +EMQX Cloud serverless β€” the flagship mesh broker (042 D4) β€” is TLS-only. +The Embassy MQTT client ([`embassy_client.rs`](../../aimdb-mqtt-connector/src/embassy_client.rs)) +speaks plain TCP to an IPv4 literal, unauthenticated. Until it can do +`mqtts://` + username/password, an MCU cannot reach the public mesh at all: +gamma's `build.rs` deliberately rejects `mqtts://` profiles, and the +`MQTT_USERNAME`/`MQTT_PASSWORD` consts its `MESH_CONFIG` embedding generates +are dead code. + +TLS on a Cortex-M without an OS means three problems the desktop client gets +for free: + +- **The TLS session itself** β€” record layer, handshake, certificate + verification, all `no_std`. +- **Entropy** β€” key exchange needs a CSPRNG; the STM32H5 has an on-chip + TRNG (gamma already binds its `rng` interrupt for the network-stack seed). +- **Wall-clock time** β€” certificate validity checking needs the current + Unix time; the reference board has no RTC battery, so time must come from + the network (SNTP) after DHCP and *before* the first handshake. + +Additionally the broker is now a **hostname**, not an IP (serverless +deployments get a DNS name, and SNI is mandatory on shared ingress), so the +client also needs DNS resolution. + +## 2. Decisions + +| # | Decision | Rationale | +|---|---|---| +| 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. | +| 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. | +| 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. | +| 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. | +| 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. | +| 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. | +| 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. | +| 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://`. | +| 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. | +| 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. | + +## 3. Non-goals + +- **Mutual TLS / client certificates** and **PSK** β€” the flagship + authenticates with per-slot username/password (042 Β§6); embedded-tls + supports both if a deployment ever needs them. +- **Session resumption / early data** β€” reconnects redo the full handshake; + at weather cadence that is fine. +- **TLS or DNS for the plain-TCP path** β€” unchanged by design (D6, D7). +- **Tokio client changes** β€” `mqtts://` already works there. +- **Removing gamma's/the template's `mqtt://`-only guard sight unseen** β€” + gamma's guard is lifted here because the same commit wires the TLS it + guards against; the *template's* guard (WP7 scope item 3) falls only after + issue 006 lands and an end-to-end `mqtts://` profile run passes on + hardware. + +## 4. Connector API + +```rust +use aimdb_mqtt_connector::embassy_client::{MqttConnectorBuilder, TlsOptions}; + +static TLS_READ_BUF: StaticCell<[u8; 16_640]> = StaticCell::new(); +static TLS_WRITE_BUF: StaticCell<[u8; 4_096]> = StaticCell::new(); +static TLS_RNG: StaticCell> = StaticCell::new(); + +let mqtt = MqttConnectorBuilder::new("mqtts://xxxx.eu-central-1.emqx.cloud:8883", stack) + .with_client_id("station-17") + .with_credentials(MQTT_USERNAME, MQTT_PASSWORD) + .with_tls(TlsOptions::new( + TLS_RNG.init(rng), // &'static mut dyn CryptoRngCore (D3) + CA_DER, // &'static [u8], root CA, DER (D2) + TLS_READ_BUF.init([0; 16_640]), + TLS_WRITE_BUF.init([0; 4_096]), + )); +``` + +`TlsOptions::new` defaults the SNTP server to `pool.ntp.org`; +`.with_sntp_server(host)` overrides it (D4). The builder keeps its existing +shape β€” `ConnectorBuilder::build()` decides plain vs TLS from the URL scheme +(D9) and returns the same `Vec` of runner futures, now including the SNTP +task when TLS is on. + +## 5. The TLS manager loop (`run_tls`) + +Per connection attempt, in order, every step logged and every failure +falling through to the reconnect delay (same policy as upstream `run()`): + +1. **Wait for time** β€” until `sntp::unix_now()` is `Some` (D5; first boot + only, the offset persists across reconnects). +2. **Resolve** the broker host (`dns_query`, A record) (D7). +3. **TCP connect** on a socket borrowed from the same 4 KB rx/tx buffers the + plain path sizes. +4. **TLS open** β€” `TlsConnection::new(socket, read_buf, write_buf)` + + `open()` with SNI = URL host, cipher suite `TLS_AES_128_GCM_SHA256`, and + a `CryptoProvider` combining the injected RNG with + `rustpki::CertVerifier` over the + embedded CA (D2, D3). +5. **MQTT session** β€” `ConnectionEmbedded::new(tls_connection)` into + `ClientNoQueue`, then the ported loop: CONNECT with credentials (D8), + re-subscribe the inbound routes (per session, so inbound routing survives + reconnects β€” the plain path queues subscriptions only once at startup), + then ping/poll/action-drain with the same `Settings` timeouts, + `MqttEvent`s, and pending-action retry semantics as upstream. + +The buffers and RNG are `&'static mut` reborrowed each iteration β€” attempts +are strictly sequential, so this satisfies the borrow checker without +copies. The whole task is boxed through the adapter's force-`Send` +`into_box_future`, under the same single-core-executor invariant as the rest +of the connector. + +## 6. SNTP (`sntp` module) + +Minimal SNTPv4 client (RFC 4330 subset), `embassy-tls`-gated: + +- one UDP socket, one 48-byte request (`LI=0, VN=4, Mode=client`), transmit + timestamp taken from the reply, sanity-checked (`Mode=server`, + `stratum 1..=15`, non-zero timestamp); +- global state is a single `AtomicU32` β€” Unix seconds minus + `embassy_time::Instant::now().as_secs()` at sync β€” read by + `unix_now() -> Option` and the `SntpClock: TlsClock` impl; +- the task retries every 10 s until the first sync, then re-syncs hourly; + the server hostname resolves through the same DNS as D7. + +The `u32` offset and second resolution are deliberate: certificate validity +has day granularity, and the representation is unambiguous until 2106. +(The NTP-era rollover in 2036 is a documented limitation of the on-wire +format shared by every SNTP consumer; a future era-aware fix is contained in +one parse function.) + +## 7. Gamma wiring + +- **`build.rs`**: the `mqtt://`-only panic is replaced by real `mqtts://` + handling β€” default port 8883, `cargo:rustc-cfg=mesh_tls`, and CA embedding: + `MESH_CA` (env var, path to the root CA in PEM or DER) is decoded to DER in + `OUT_DIR/ca.der` and surfaced as `MQTT_CA_DER`. A `mqtts://` profile + without `MESH_CA` is a build error with instructions. Plain profiles and + the no-profile local demo generate byte-identical config to today. +- **`main.rs`**: TLS statics (RNG cell, record buffers) and the + `.with_credentials(...)`/`.with_tls(...)` calls sit behind + `#[cfg(mesh_tls)]`, so a plain-demo build carries zero TLS code or RAM β€” + the same compile-time sim-to-real philosophy as design 041. The TRNG + moves into a `StaticCell` so the one instance seeds the net stack *and* + feeds TLS. `StackResources` grows 3 β†’ 5 (DNS + SNTP sockets). + +## 8. Cost + +On the reference STM32H563ZI (640 KB RAM / 2 MB flash), the TLS build adds +~21 KB of statically-allocated RAM (16 640 + 4 096 record buffers, D10) plus +the handshake's stack transient, and roughly 150–250 KB of flash +(p256 + rsa + p384 + SHA-2 + embedded-tls). Comfortable here; a smaller +part could drop `rsa`/`p384` (features are additive pass-throughs) and pin +its broker CA chain to P-256. + +## 9. Open questions + +- **CA distribution.** The firmware embeds the root CA (D2), but the station + profile doesn't carry one β€” joining the flagship from an MCU needs the CA + file fetched out of band (`MESH_CA=isrg-root-x1.pem`). Proposal for a 043 + revision: optional `broker.ca` (PEM) in the profile, emitted by the + provisioning service; gamma's `build.rs` would prefer it over `MESH_CA`. + Belongs to WP2/WP7 follow-up once the EMQX tier (issue 007) fixes the + actual chain. +- **Record-size negotiation.** embedded-tls offers `max_fragment_length`, + but RFC 8449 `record_size_limit` is what modern stacks respect; if EMQX + honours either, the 16 KB read buffer could shrink substantially. Measure + against the real broker before optimising. + +## 10. Verification + +- Host: existing `make check`/`make test` legs, plus unit tests for the URL + scheme/port/TLS-option validation and the SNTP packet codec. +- Cross: the existing thumbv7em clippy leg gains an + `embassy-runtime,embassy-tls,defmt` variant; gamma builds for + thumbv8m.main with a synthetic `mqtts://` profile + `MESH_CA` (compile + proof of the full wiring) and without one (plain demo unchanged). +- Hardware (the WP7 acceptance): gamma + real mesh profile connects, + authenticates, publishes through the TLS broker β€” runs on the bench once + a broker credential exists (issue 007), tracked in the WP7 issue. From d438c1919fbeb9c475948a584b5a950a89af7e76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 10 Jul 2026 17:25:26 +0000 Subject: [PATCH 02/11] feat(mqtt-connector): TLS, auth, and SNTP time source for the Embassy client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements design 044: mqtts:// broker support behind a new embassy-tls feature. - embedded-tls 1.3 session over the Embassy TCP socket; rustpki (pure Rust, rsa+p384) certificate verification against an app-embedded root CA; SNI + hostname verification from the broker URL (IP-literal mqtts URLs are rejected at build()). - Entropy injected via TlsOptions (&'static mut dyn CryptoRngCore) β€” the app owns the TRNG; buffers are app-provided statics (D3, D10). - Connector-internal SNTPv4 task (UDP + DNS) backs the TlsClock; the first handshake waits for time sync so validity is always checked (D4, D5). The wire codec is feature-independent and host-tested. - The TLS manager loop is a connector-local port of mountain-mqtt-embassy's run() (which hard-codes a plain TcpSocket); the session speaks a custom Connection impl tracking decrypted plaintext + socket readiness, and re-subscribes inbound routes per session. The plain path is untouched (D6). - with_credentials() feeds MQTT CONNECT on both transports; the fork submodule gains upstream 0.4's ConnectionSettings::with_auth/ authenticated (aimdb-dev/mountain-mqtt@89a7129) (D8). - make check gains the embassy-runtime,embassy-tls,defmt clippy leg. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 500 +++++++++++++++++- Makefile | 2 + _external/mountain-mqtt | 2 +- aimdb-mqtt-connector/Cargo.toml | 21 + aimdb-mqtt-connector/src/embassy_client.rs | 289 ++++++++-- aimdb-mqtt-connector/src/embassy_tls.rs | 584 +++++++++++++++++++++ aimdb-mqtt-connector/src/lib.rs | 13 + aimdb-mqtt-connector/src/sntp.rs | 125 +++++ aimdb-mqtt-connector/src/sntp_codec.rs | 92 ++++ 9 files changed, 1583 insertions(+), 45 deletions(-) create mode 100644 aimdb-mqtt-connector/src/embassy_tls.rs create mode 100644 aimdb-mqtt-connector/src/sntp.rs create mode 100644 aimdb-mqtt-connector/src/sntp_codec.rs diff --git a/Cargo.lock b/Cargo.lock index 9eb299a..0d1b6ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -220,11 +255,14 @@ dependencies = [ "embassy-net", "embassy-sync", "embassy-time", + "embedded-io-async 0.7.0", + "embedded-tls", "futures-core", "futures-util", "heapless 0.8.0", "mountain-mqtt", "mountain-mqtt-embassy", + "rand_core 0.6.4", "rumqttc", "static_cell", "thiserror 2.0.17", @@ -580,6 +618,12 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.13.1" @@ -592,6 +636,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit_field" version = "0.10.3" @@ -737,6 +787,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.51" @@ -817,6 +877,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cordyceps" version = "0.3.4" @@ -956,6 +1028,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -966,6 +1050,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "darling" version = "0.20.11" @@ -1072,6 +1165,45 @@ dependencies = [ "defmt 1.0.1", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "der_derive", + "heapless 0.9.1", + "time", +] + +[[package]] +name = "der_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -1079,7 +1211,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid 0.9.6", "crypto-common", + "subtle", ] [[package]] @@ -1112,12 +1246,44 @@ dependencies = [ "serde", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest", + "elliptic-curve", + "rfc6979", + "signature", +] + [[package]] name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embassy-bench-stm32h5" version = "0.1.0" @@ -1554,6 +1720,33 @@ dependencies = [ "embedded-storage", ] +[[package]] +name = "embedded-tls" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff3c16c9cedcab3ef5c79fd46349152adbb98f997b937cb1ead7655f5c07e43" +dependencies = [ + "aes-gcm", + "const-oid 0.10.2", + "der 0.8.1", + "digest", + "ecdsa", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "generic-array", + "heapless 0.9.1", + "hkdf", + "hmac", + "p256", + "p384", + "portable-atomic", + "rand_core 0.6.4", + "rsa", + "sha2", + "signature", + "typenum", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1617,6 +1810,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.4" @@ -1797,6 +2000,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1836,12 +2040,33 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -1975,6 +2200,24 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -2249,6 +2492,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "io-kit-sys" version = "0.4.1" @@ -2340,6 +2592,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2602,6 +2857,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2634,6 +2930,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.80" @@ -2677,6 +2979,30 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "panic-probe" version = "1.0.0" @@ -2739,12 +3065,45 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -2763,6 +3122,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2782,6 +3147,15 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2834,13 +3208,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.3", ] @@ -2855,6 +3239,16 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2989,6 +3383,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3015,6 +3419,27 @@ dependencies = [ "svgbobdoc", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rumqttc" version = "0.25.1" @@ -3151,6 +3576,19 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b328e2cb950eeccd55b7f55c3a963691455dcd044cfb5354f0c5e68d2c2d6ee2" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3332,6 +3770,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3356,6 +3805,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "slab" version = "0.4.11" @@ -3401,6 +3860,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3659,6 +4128,24 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.2" @@ -4148,6 +4635,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4465,6 +4962,7 @@ dependencies = [ "aimdb-data-contracts", "aimdb-embassy-adapter", "aimdb-mqtt-connector", + "base64 0.22.1", "cortex-m", "cortex-m-rt", "critical-section", diff --git a/Makefile b/Makefile index da9602a..9be54d2 100644 --- a/Makefile +++ b/Makefile @@ -252,6 +252,8 @@ clippy: cargo clippy --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime" -- -D warnings @printf "$(YELLOW) β†’ Clippy on MQTT connector (embassy + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" -- -D warnings + @printf "$(YELLOW) β†’ Clippy on MQTT connector (embassy + TLS + defmt)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,embassy-tls,defmt" -- -D warnings @printf "$(YELLOW) β†’ Clippy on KNX connector (embassy + defmt)$(NC)\n" cargo clippy --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" -- -D warnings @printf "$(YELLOW) β†’ Clippy on WS protocol$(NC)\n" diff --git a/_external/mountain-mqtt b/_external/mountain-mqtt index 10754e8..89a7129 160000 --- a/_external/mountain-mqtt +++ b/_external/mountain-mqtt @@ -1 +1 @@ -Subproject commit 10754e831f3140f7a90e8ce912d1a1483c875f91 +Subproject commit 89a7129a39818ebacb0b2c676a71e2df585e71ce diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 4ff4091..6399170 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -38,6 +38,18 @@ embassy-runtime = [ "heapless", "static_cell", ] +# TLS (`mqtts://`) for the Embassy client β€” design 044. embedded-tls 1.3 +# session over the Embassy TCP socket, pure-Rust certificate verification +# (`rustpki`; `rsa`/`p384` so public CA chains verify out of the box), broker +# hostname resolution (embassy-net DNS), and the SNTP time source (UDP). +embassy-tls = [ + "embassy-runtime", + "dep:embedded-tls", + "dep:embedded-io-async", + "dep:rand_core", + "embassy-net/dns", + "embassy-net/udp", +] tracing = ["dep:tracing", "aimdb-core/tracing"] defmt = ["dep:defmt", "aimdb-core/defmt"] @@ -84,6 +96,15 @@ mountain-mqtt = { version = "0.2.0", default-features = false, optional = true, ] } mountain-mqtt-embassy = { version = "0.2.0", optional = true } +# TLS for the Embassy client (no_std TLS 1.3; design 044) +embedded-tls = { version = "0.19", default-features = false, optional = true, features = [ + "rustpki", + "rsa", + "p384", +] } +embedded-io-async = { workspace = true, optional = true } +rand_core = { version = "0.6", default-features = false, optional = true } + # Embedded utilities heapless = { workspace = true, optional = true } static_cell = { version = "2.0", optional = true } diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 78e93a9..9c4d023 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -71,14 +71,19 @@ use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; use mountain_mqtt_embassy::mqtt_manager::{self, MqttEvent, Settings}; +#[cfg(feature = "embassy-tls")] +pub use crate::embassy_tls::TlsOptions; +#[cfg(feature = "embassy-tls")] +use crate::embassy_tls::{host_is_ip_literal, run_tls}; + /// Maximum number of pending MQTT actions and events -const CHANNEL_SIZE: usize = 32; +pub(crate) const CHANNEL_SIZE: usize = 32; /// Buffer size for MQTT packets (4KB) -const BUFFER_SIZE: usize = 4096; +pub(crate) const BUFFER_SIZE: usize = 4096; /// Maximum properties in MQTT packets -const MAX_PROPERTIES: usize = 16; +pub(crate) const MAX_PROPERTIES: usize = 16; /// The runner's collected future type. type EmbassyBoxFuture = Pin + Send + 'static>>; @@ -269,13 +274,38 @@ impl EmbassySourceRaw for MqttSource { } } +/// Force-`Send + Sync` slot for the TLS materials: [`TlsOptions`] holds +/// `&'static mut` exclusive resources (TRNG, record buffers), so it is +/// neither `Sync` nor takeable through the `&self` that +/// [`ConnectorBuilder::build`] receives without interior mutability. +/// +/// SAFETY invariant: same single-core cooperative-executor invariant as +/// [`NetStack`](aimdb_embassy_adapter::connectors::NetStack); the slot is +/// written by `with_tls` and taken exactly once inside `build()`, both on +/// that executor. +#[cfg(feature = "embassy-tls")] +struct TlsSlot(core::cell::RefCell>); + +// SAFETY: see the struct-level invariant. +#[cfg(feature = "embassy-tls")] +unsafe impl Send for TlsSlot {} +// SAFETY: see the struct-level invariant. +#[cfg(feature = "embassy-tls")] +unsafe impl Sync for TlsSlot {} + /// MQTT connector builder for Embassy with router-based dispatch. /// /// Collects routes from the database during `build()` and wires the broker -/// manager + the outbound/inbound pumps. +/// manager + the outbound/inbound pumps. The broker URL scheme selects the +/// transport (design 044 D9): `mqtt://` is plain TCP (default port 1883), +/// `mqtts://` is TLS (default port 8883) and requires both the `embassy-tls` +/// feature and [`with_tls`](Self::with_tls). pub struct MqttConnectorBuilder { broker_url: String, client_id: String, + credentials: Option<(String, String)>, + #[cfg(feature = "embassy-tls")] + tls: TlsSlot, stack: aimdb_embassy_adapter::connectors::NetStack, } @@ -283,13 +313,17 @@ impl MqttConnectorBuilder { /// Create a new MQTT connector builder for Embassy. /// /// # Arguments - /// * `broker_url` - Broker URL in format `mqtt://host:port` + /// * `broker_url` - Broker URL in format `mqtt://host:port` (plain TCP) + /// or `mqtts://host:port` (TLS, see [`with_tls`](Self::with_tls)) /// * `stack` - The device's network stack (the runtime travels as /// `Arc` and cannot surface it) pub fn new(broker_url: impl Into, stack: &'static embassy_net::Stack<'static>) -> Self { Self { broker_url: broker_url.into(), client_id: "aimdb-client".to_string(), + credentials: None, + #[cfg(feature = "embassy-tls")] + tls: TlsSlot(core::cell::RefCell::new(None)), // SAFETY: AimDB's Embassy integration requires a single-core // cooperative executor (the adapter's module-level invariant); // every future touching this stack β€” including the broker task @@ -303,6 +337,28 @@ impl MqttConnectorBuilder { self.client_id = client_id.into(); self } + + /// Authenticate with the broker (MQTT CONNECT username/password). + /// + /// Works on both transports, but note that over `mqtt://` the credential + /// transits in cleartext β€” pair it with `mqtts://` outside a trusted LAN. + pub fn with_credentials( + mut self, + username: impl Into, + password: impl Into, + ) -> Self { + self.credentials = Some((username.into(), password.into())); + self + } + + /// Provide the TLS materials for an `mqtts://` broker (design 044 Β§4). + /// + /// Required for `mqtts://` URLs; rejected at `build()` for `mqtt://`. + #[cfg(feature = "embassy-tls")] + pub fn with_tls(self, options: TlsOptions) -> Self { + *self.tls.0.borrow_mut() = Some(options); + self + } } /// Implement ConnectorBuilder trait for Embassy. @@ -332,9 +388,43 @@ impl ConnectorBuilder for MqttConnectorBuilder { #[cfg(feature = "defmt")] defmt::info!("MQTT: subscribing to {} inbound topics", topics.len()); - // Broker manager task + the channel ends for the pumps. - let (action_sender, event_receiver, manager_task) = - setup_manager(&self.broker_url, &self.client_id, self.stack, topics)?; + let broker = parse_broker_url(&self.broker_url)?; + let connection_settings = + static_connection_settings(&self.client_id, self.credentials.as_ref()); + + // Broker manager task(s) + the channel ends for the pumps. + // The URL scheme selects the transport (design 044 D9). + #[cfg(feature = "embassy-tls")] + let (action_sender, event_receiver, manager_tasks) = { + let tls_options = self.tls.0.borrow_mut().take(); + match (broker.tls, tls_options) { + (true, Some(options)) => setup_tls_manager( + &broker, + options, + connection_settings, + self.stack, + topics, + )?, + (true, None) => { + return Err(build_err("mqtts:// broker URLs require .with_tls(...)")) + } + (false, Some(_)) => { + return Err(build_err(".with_tls(...) requires an mqtts:// broker URL")) + } + (false, None) => { + setup_manager(&broker, connection_settings, self.stack, topics)? + } + } + }; + #[cfg(not(feature = "embassy-tls"))] + let (action_sender, event_receiver, manager_tasks) = { + if broker.tls { + return Err(build_err( + "mqtts:// broker URLs require the `embassy-tls` feature of aimdb-mqtt-connector", + )); + } + setup_manager(&broker, connection_settings, self.stack, topics)? + }; // Outbound publishes + inbound routing ride core's pumps. let mut futures = pump_sink( @@ -351,8 +441,9 @@ impl ConnectorBuilder for MqttConnectorBuilder { receiver: event_receiver, }), )); - // The broker manager protocol loop (force-`Send` via the adapter). - futures.push(manager_task); + // The broker manager protocol loop (plus the SNTP time-source + // task on the TLS path), force-`Send` via the adapter. + futures.extend(manager_tasks); Ok(futures) }) @@ -363,41 +454,73 @@ impl ConnectorBuilder for MqttConnectorBuilder { } } -/// Set up the static channels + the mountain-mqtt broker manager task, returning -/// the action sender (outbound), the event receiver (inbound), and the manager -/// task future (which first queues the topic subscriptions, then runs the broker -/// loop). Synchronous β€” no `.await` β€” so the caller's `build` future stays `Send`. -fn setup_manager( - broker_url: &str, - client_id: &str, - stack: aimdb_embassy_adapter::connectors::NetStack, - topics: Vec, -) -> Result<(ActionSender, EventReceiver, EmbassyBoxFuture), aimdb_core::DbError> { - let build_err = |msg: &str| { - #[cfg(feature = "defmt")] - defmt::error!("Failed to build MQTT connector: {}", msg); - aimdb_core::DbError::runtime_error(format!("Failed to build MQTT connector: {}", msg)) - }; +/// Parsed broker endpoint: transport + authority (design 044 D9). +struct BrokerUrl { + tls: bool, + host: String, + port: u16, +} - // Parse the broker URL (add a dummy topic if none, so parsing succeeds). +fn build_err(msg: &str) -> aimdb_core::DbError { + #[cfg(feature = "defmt")] + defmt::error!("Failed to build MQTT connector: {}", msg); + aimdb_core::DbError::runtime_error(format!("Failed to build MQTT connector: {}", msg)) +} + +/// Parse the broker URL into transport + host + port (`mqtt://` 1883, +/// `mqtts://` 8883). +fn parse_broker_url(broker_url: &str) -> Result { + // Add a dummy topic if none, so parsing succeeds. let mut url = broker_url.to_string(); if !url.contains('/') || url.matches('/').count() < 3 { url = format!("{}/dummy", url.trim_end_matches('/')); } let connector_url = ConnectorUrl::parse(&url).map_err(|_| build_err("Invalid MQTT URL"))?; - let host = connector_url.host.clone(); - let port = connector_url.port.unwrap_or(1883); - - let broker_ip = - Ipv4Addr::from_str(&host).map_err(|_| build_err("Invalid broker IP address"))?; - let octets = broker_ip.octets(); - let broker_addr = Ipv4Address::new(octets[0], octets[1], octets[2], octets[3]); + let tls = match connector_url.scheme.as_str() { + "mqtt" => false, + "mqtts" => true, + _ => return Err(build_err("Broker URL scheme must be mqtt:// or mqtts://")), + }; + let port = connector_url.port.unwrap_or(if tls { 8883 } else { 1883 }); + Ok(BrokerUrl { + tls, + host: connector_url.host, + port, + }) +} - // Store client_id in static memory for the 'static lifetime requirement. +/// Build the `ConnectionSettings<'static>` for MQTT CONNECT, parking the +/// identity strings in statics for the `'static` lifetime requirement. +fn static_connection_settings( + client_id: &str, + credentials: Option<&(String, String)>, +) -> ConnectionSettings<'static> { static CLIENT_ID_STORAGE: OnceLock = OnceLock::new(); - let client_id_static: &'static str = CLIENT_ID_STORAGE.get_or_init(|| client_id.to_string()); + static CREDENTIALS_STORAGE: OnceLock<(String, String)> = OnceLock::new(); + + let client_id: &'static str = CLIENT_ID_STORAGE.get_or_init(|| client_id.to_string()); + match credentials { + Some(credentials) => { + let credentials: &'static (String, String) = + CREDENTIALS_STORAGE.get_or_init(|| credentials.clone()); + ConnectionSettings::authenticated( + client_id, + credentials.0.as_str(), + credentials.1.as_bytes(), + ) + } + None => ConnectionSettings::unauthenticated(client_id), + } +} + +/// Sender half of the event channel (used by the broker manager tasks). +type EventSender = Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>; +/// Receiver half of the action channel (drained by the broker manager tasks). +type ActionReceiver = Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>; - // Static channels for MQTT communication. +/// Initialise the static action/event channels shared by both transports +/// (one MQTT connector per firmware β€” `StaticCell` enforces single init). +fn init_channels() -> (ActionSender, ActionReceiver, EventSender, EventReceiver) { static ACTION_CHANNEL: StaticCell> = StaticCell::new(); static EVENT_CHANNEL: StaticCell< @@ -406,13 +529,34 @@ fn setup_manager( let action_channel = ACTION_CHANNEL.init(Channel::new()); let event_channel = EVENT_CHANNEL.init(Channel::new()); - let action_sender = action_channel.sender(); - let action_receiver = action_channel.receiver(); - let event_sender = event_channel.sender(); - let event_receiver = event_channel.receiver(); + ( + action_channel.sender(), + action_channel.receiver(), + event_channel.sender(), + event_channel.receiver(), + ) +} - let connection_settings = ConnectionSettings::unauthenticated(client_id_static); - let settings = Settings::new(broker_addr, port); +/// Set up the plain-TCP broker manager (mountain-mqtt-embassy's `run`), +/// returning the action sender (outbound), the event receiver (inbound), and +/// the manager task future (which first queues the topic subscriptions, then +/// runs the broker loop). Synchronous β€” no `.await` β€” so the caller's `build` +/// future stays `Send`. +fn setup_manager( + broker: &BrokerUrl, + connection_settings: ConnectionSettings<'static>, + stack: aimdb_embassy_adapter::connectors::NetStack, + topics: Vec, +) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> { + let broker_ip = Ipv4Addr::from_str(&broker.host).map_err(|_| { + build_err("Invalid broker IP address (plain mqtt:// needs an IPv4 literal)") + })?; + let octets = broker_ip.octets(); + let broker_addr = Ipv4Address::new(octets[0], octets[1], octets[2], octets[3]); + + let (action_sender, action_receiver, event_sender, event_receiver) = init_channels(); + + let settings = Settings::new(broker_addr, broker.port); let network = stack.get(); // Manager task: queue subscriptions, then run the broker loop (never returns). @@ -449,7 +593,66 @@ fn setup_manager( } }); - Ok((action_sender, event_receiver, manager_task)) + Ok((action_sender, event_receiver, alloc::vec![manager_task])) +} + +/// Set up the TLS broker manager ([`run_tls`]) plus the SNTP time-source +/// task (design 044 Β§5–§6). Synchronous β€” no `.await` β€” so the caller's +/// `build` future stays `Send`. +#[cfg(feature = "embassy-tls")] +fn setup_tls_manager( + broker: &BrokerUrl, + options: TlsOptions, + connection_settings: ConnectionSettings<'static>, + stack: aimdb_embassy_adapter::connectors::NetStack, + topics: Vec, +) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> { + if host_is_ip_literal(&broker.host) { + return Err(build_err( + "mqtts:// needs a hostname β€” certificate verification cannot match an IP literal", + )); + } + + let (action_sender, action_receiver, event_sender, event_receiver) = init_channels(); + + // `Settings` supplies the session cadence and port; its address field is + // unused on the TLS path (the host is resolved per attempt, 044 D7). + let settings = Settings::new(Ipv4Address::UNSPECIFIED, broker.port); + let network = stack.get(); + let host = broker.host.clone(); + let sntp_server = options.sntp_server; + + let manager_task = into_box_future(async move { + #[cfg(feature = "defmt")] + defmt::info!("MQTT-TLS background task starting"); + + #[allow(unreachable_code)] + { + let _: () = run_tls( + *network, + options, + host, + topics, + connection_settings, + settings, + event_sender, + action_receiver, + ) + .await; + } + }); + let sntp_task = into_box_future(async move { + #[allow(unreachable_code)] + { + let _: () = crate::sntp::run(*network, sntp_server).await; + } + }); + + Ok(( + action_sender, + event_receiver, + alloc::vec![manager_task, sntp_task], + )) } /// Map a QoS level (0/1/2) to mountain-mqtt's `QualityOfService` (2 downgrades to 1). diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embassy_tls.rs new file mode 100644 index 0000000..736deac --- /dev/null +++ b/aimdb-mqtt-connector/src/embassy_tls.rs @@ -0,0 +1,584 @@ +//! TLS transport for the Embassy MQTT client (design 044). +//! +//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the Embassy +//! TCP socket, wrapped in mountain-mqtt's [`ConnectionEmbedded`] so the MQTT +//! layer is identical to the plain path. Certificate verification is +//! `rustpki` (pure Rust) against the application-embedded root CA, with time +//! from the [`sntp`](crate::sntp) task; entropy comes from the +//! application-injected TRNG ([`TlsOptions::new`]). +//! +//! The session loop ([`handle_messages`], [`State`], [`ChannelEventHandler`]) +//! is a port of mountain-mqtt-embassy's private equivalents (MIT OR +//! Apache-2.0): upstream `run()` constructs a bare `TcpSocket` internally and +//! offers no seam for a TLS session (design 044 D6). Keep the loop in sync +//! with the fork when bumping it. + +use alloc::string::String; +use alloc::vec::Vec; +use core::cell::RefCell; +use core::net::Ipv4Addr; + +use embassy_net::dns::DnsQueryType; +use embassy_net::tcp::TcpSocket; +use embassy_net::{IpAddress, Stack}; +use embassy_sync::blocking_mutex::raw::NoopRawMutex; +use embassy_sync::channel::{Receiver, Sender}; +use embassy_time::{Delay, Instant, Timer}; + +use embedded_tls::pki::CertVerifier; +use embedded_tls::{ + Aes128GcmSha256, Certificate, CryptoProvider, CryptoRngCore, TlsConfig, TlsConnection, + TlsContext, TlsError, TlsVerifier, +}; + +use embedded_io_async::Write as _; + +use mountain_mqtt::client::{ + Client, ClientError, ClientNoQueue, ClientReceivedEvent, ConnectionSettings, EventHandler, + EventHandlerError, +}; +use mountain_mqtt::data::quality_of_service::QualityOfService; +use mountain_mqtt::embedded_hal_async::DelayEmbedded; +use mountain_mqtt::error::{PacketReadError, PacketWriteError}; +use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; +use mountain_mqtt::packet_client::Connection; +use mountain_mqtt_embassy::mqtt_manager::{Error, FromApplicationMessage, MqttEvent, Settings}; + +use crate::embassy_client::{ + AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES, +}; +use crate::sntp::{self, SntpClock}; + +/// Room for the server's leaf certificate (DER) inside the verifier β€” 4 KB +/// covers RSA-4096 leaves with headroom. +const CERT_BUFFER_SIZE: usize = 4096; + +/// TLS materials for a `mqtts://` broker connection (design 044 Β§4). +/// +/// All references are `'static`: the session outlives `build()`, so buffers +/// and the RNG live in `StaticCell`s (or equivalents) owned by the +/// application β€” the one party that knows the board's memory budget. +pub struct TlsOptions { + pub(crate) rng: &'static mut dyn CryptoRngCore, + pub(crate) ca_der: &'static [u8], + pub(crate) read_buf: &'static mut [u8], + pub(crate) write_buf: &'static mut [u8], + pub(crate) sntp_server: &'static str, +} + +impl TlsOptions { + /// TLS with certificate verification against `ca_der` (the root CA, DER). + /// + /// * `rng` β€” CSPRNG for the handshake; on STM32 the hardware TRNG + /// (`embassy_stm32::rng::Rng` implements `CryptoRngCore`). + /// * `read_buf` β€” TLS record read buffer. 16 640 bytes recommended: a + /// TLS 1.3 peer may send full-size records regardless of our + /// `max_fragment_length` offer. + /// * `write_buf` β€” TLS record write buffer; 4 096 bytes is plenty for + /// MQTT-sized writes. + pub fn new( + rng: &'static mut dyn CryptoRngCore, + ca_der: &'static [u8], + read_buf: &'static mut [u8], + write_buf: &'static mut [u8], + ) -> Self { + Self { + rng, + ca_der, + read_buf, + write_buf, + sntp_server: "pool.ntp.org", + } + } + + /// Override the SNTP server used as the certificate-validation time + /// source (default `pool.ntp.org`). + pub fn with_sntp_server(mut self, server: &'static str) -> Self { + self.sntp_server = server; + self + } +} + +/// The TCP socket shared between the TLS session (its transport) and the +/// MQTT-level readiness probe ([`TlsSession::receive_if_ready`]), which needs +/// `can_recv()` after the socket has been handed to `embedded-tls`. +/// +/// Borrow discipline: the session task drives exactly one client operation at +/// a time, so a `borrow_mut` held across an I/O `.await` can never overlap +/// the probe's short `borrow` β€” both are called sequentially from the same +/// loop. +struct SharedTcp<'r, 'a>(&'r RefCell>); + +impl Clone for SharedTcp<'_, '_> { + fn clone(&self) -> Self { + Self(self.0) + } +} + +impl SharedTcp<'_, '_> { + fn can_recv(&self) -> bool { + self.0.borrow().can_recv() + } +} + +impl embedded_io_async::ErrorType for SharedTcp<'_, '_> { + type Error = embassy_net::tcp::Error; +} + +// The held-across-await borrows below are safe by the struct-level borrow +// discipline (sequential single-task use); a panic would mean a second client +// operation ran concurrently, which the session loop cannot do. +#[allow(clippy::await_holding_refcell_ref)] +impl embedded_io_async::Read for SharedTcp<'_, '_> { + async fn read(&mut self, buf: &mut [u8]) -> Result { + self.0.borrow_mut().read(buf).await + } +} + +#[allow(clippy::await_holding_refcell_ref)] +impl embedded_io_async::Write for SharedTcp<'_, '_> { + async fn write(&mut self, buf: &[u8]) -> Result { + self.0.borrow_mut().write(buf).await + } + + async fn flush(&mut self) -> Result<(), Self::Error> { + self.0.borrow_mut().flush().await + } +} + +/// mountain-mqtt [`Connection`] over an open TLS session. +/// +/// Not `ConnectionEmbedded`: that adapter needs `ReadReady`, which +/// [`TlsConnection`] cannot offer β€” and TLS readiness is two-layered anyway. +/// Data can be ready as already-decrypted plaintext left over from a record +/// that carried more than one MQTT packet (`plaintext_remaining`), or as +/// undecrypted bytes on the wire (`can_recv` on the shared socket). Checking +/// both keeps coalesced packets flowing promptly. +/// +/// Known limitation (documented in design 044): wire bytes that decrypt to +/// *no* application data (unsolicited session tickets, KeyUpdate) make +/// `receive` wait for the next real record; if the broker stays silent, the +/// keep-alive lapse tears the session down and the manager reconnects. +struct TlsSession<'r, 'a, 'b> { + tls: TlsConnection<'b, SharedTcp<'r, 'a>, Aes128GcmSha256>, + socket: SharedTcp<'r, 'a>, + /// Decrypted-but-unread plaintext left in the TLS record buffer. + plaintext_remaining: usize, +} + +impl Connection for TlsSession<'_, '_, '_> { + async fn send(&mut self, buf: &[u8]) -> Result<(), PacketWriteError> { + self.tls + .write_all(buf) + .await + .map_err(|_| PacketWriteError::ConnectionSend)?; + self.tls + .flush() + .await + .map_err(|_| PacketWriteError::ConnectionSend) + } + + async fn receive(&mut self, buf: &mut [u8]) -> Result<(), PacketReadError> { + let mut filled = 0; + while filled < buf.len() { + let mut read_buffer = self + .tls + .read_buffered() + .await + .map_err(|_| PacketReadError::ConnectionReceive)?; + filled += read_buffer.pop_into(&mut buf[filled..]); + self.plaintext_remaining = read_buffer.len(); + } + Ok(()) + } + + async fn receive_if_ready(&mut self, buf: &mut [u8]) -> Result { + if self.plaintext_remaining == 0 && !self.socket.can_recv() { + return Ok(false); + } + self.receive(buf).await?; + Ok(true) + } +} + +/// [`CryptoProvider`] pairing the injected TRNG with `rustpki` certificate +/// verification (time from [`SntpClock`]). Client-certificate signing is +/// deliberately absent β€” the mesh authenticates with MQTT credentials +/// (design 044 non-goals). +struct TrngProvider<'a> { + rng: &'a mut dyn CryptoRngCore, + verifier: CertVerifier<'static, Aes128GcmSha256, SntpClock, CERT_BUFFER_SIZE>, +} + +impl CryptoProvider for TrngProvider<'_> { + type CipherSuite = Aes128GcmSha256; + // Unused (no client certificates); any `AsRef<[u8]>` satisfies the bound. + type Signature = &'static [u8]; + + fn rng(&mut self) -> impl CryptoRngCore { + &mut *self.rng + } + + fn verifier(&mut self) -> Result<&mut impl TlsVerifier, TlsError> { + Ok(&mut self.verifier) + } +} + +/// The TLS broker manager: resolve β†’ TCP β†’ TLS handshake β†’ MQTT session, +/// reconnecting forever with the same [`Settings`] cadence as the plain +/// path's `mqtt_manager::run` (`settings.address` is unused β€” the TLS path +/// resolves `host` per attempt, design 044 D7). +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_tls( + stack: Stack<'static>, + options: TlsOptions, + host: String, + topics: Vec, + connection_settings: ConnectionSettings<'static>, + settings: Settings, + event_sender: Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>, + mut action_receiver: Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>, +) -> ! { + let TlsOptions { + rng, + ca_der, + read_buf, + write_buf, + .. + } = options; + + let mut rx_buffer = [0u8; BUFFER_SIZE]; + let mut tx_buffer = [0u8; BUFFER_SIZE]; + let mut mqtt_buffer = [0u8; BUFFER_SIZE]; + + let mut connection_index = 0u32; + + loop { + // Certificate validity needs real time β€” hold the first handshake + // until SNTP has synced (design 044 D5). + if sntp::unix_now().is_none() { + #[cfg(feature = "defmt")] + defmt::info!("MQTT-TLS: waiting for SNTP time sync..."); + while sntp::unix_now().is_none() { + Timer::after_millis(500).await; + } + } + + let address = match resolve(stack, &host).await { + Some(address) => address, + None => { + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT-TLS: DNS lookup for {} failed, will retry", + host.as_str() + ); + Timer::after(settings.reconnection_delay).await; + continue; + } + }; + + let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer); + socket.set_timeout(None); + + #[cfg(feature = "defmt")] + defmt::info!( + "MQTT-TLS: connecting to {} ({}) port {}...", + host.as_str(), + address, + settings.port + ); + if let Err(e) = socket.connect((address, settings.port)).await { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT-TLS: socket connect error, will retry: {:?}", e); + #[cfg(not(feature = "defmt"))] + let _ = e; + Timer::after(settings.reconnection_delay).await; + continue; + } + + let socket = RefCell::new(socket); + let shared = SharedTcp(&socket); + + let tls_config = TlsConfig::new().with_server_name(&host); + let mut tls = TlsConnection::new(shared.clone(), &mut *read_buf, &mut *write_buf); + let provider = TrngProvider { + rng: &mut *rng, + verifier: CertVerifier::new(Certificate::X509(ca_der)), + }; + if let Err(e) = tls.open(TlsContext::new(&tls_config, provider)).await { + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT-TLS: handshake failed, will retry: {:?}", + defmt::Debug2Format(&e) + ); + #[cfg(not(feature = "defmt"))] + let _ = e; + Timer::after(settings.reconnection_delay).await; + continue; + } + #[cfg(feature = "defmt")] + defmt::info!("MQTT-TLS: session established"); + + let connection = TlsSession { + tls, + socket: shared, + plaintext_remaining: 0, + }; + let delay = DelayEmbedded::new(Delay); + let timeout_millis = settings.response_timeout.as_millis() as u32; + + let state = RefCell::new(State::new()); + + let connection_id = ConnectionId::new(connection_index); + connection_index += 1; + + let event_handler = ChannelEventHandler { + connection_id, + event_sender: &event_sender, + state: &state, + }; + + let mut client = ClientNoQueue::new( + connection, + &mut mqtt_buffer, + delay, + timeout_millis, + event_handler, + ); + + if let Err(error) = handle_messages( + connection_id, + &mut client, + &state, + &connection_settings, + &topics, + &event_sender, + &mut action_receiver, + &settings, + ) + .await + { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT-TLS: session errored: {:?}", error); + event_sender + .send(MqttEvent::Disconnected { + connection_id, + error, + }) + .await; + } + + Timer::after(settings.reconnection_delay).await; + } +} + +/// Resolve the broker host to its first A record (IP literals short-circuit +/// inside `dns_query` without a network round trip). +async fn resolve(stack: Stack<'static>, host: &str) -> Option { + match stack.dns_query(host, DnsQueryType::A).await { + Ok(addresses) => addresses.first().copied(), + Err(_) => None, + } +} + +/// SNI requires a name, not an address β€” used by `build()` to reject broker +/// URLs that can never verify. +pub(crate) fn host_is_ip_literal(host: &str) -> bool { + host.parse::().is_ok() +} + +// --- Ported session loop (see module doc) --------------------------------- + +struct State { + /// The instant of the most recent proof the connection is live (set at + /// connect, refreshed on every Ack from the server). + last_connection_event: Instant, + /// A failed action to retry on the next loop turn / connection. + pending_action: Option, +} + +impl State { + fn new() -> Self { + Self { + last_connection_event: Instant::now(), + pending_action: None, + } + } + fn record_connection_event(&mut self) { + self.last_connection_event = Instant::now(); + } +} + +struct ChannelEventHandler<'a> { + connection_id: ConnectionId, + event_sender: &'a Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>, + state: &'a RefCell, +} + +impl EventHandler for ChannelEventHandler<'_> { + async fn handle_event( + &mut self, + event: ClientReceivedEvent<'_, MAX_PROPERTIES>, + ) -> Result<(), EventHandlerError> { + match event { + ClientReceivedEvent::ApplicationMessage(message) => { + let event = AimdbMqttEvent::from_application_message(&message)?; + self.event_sender + .send(MqttEvent::ApplicationEvent { + connection_id: self.connection_id, + event, + }) + .await; + } + ClientReceivedEvent::Ack => { + self.state.borrow_mut().record_connection_event(); + } + ClientReceivedEvent::SubscriptionGrantedBelowMaximumQos { + granted_qos, + maximum_qos, + } => { + self.event_sender + .send(MqttEvent::SubscriptionGrantedBelowMaximumQos { + connection_id: self.connection_id, + granted_qos, + maximum_qos, + }) + .await + } + ClientReceivedEvent::PublishedMessageHadNoMatchingSubscribers => { + self.event_sender + .send(MqttEvent::PublishedMessageHadNoMatchingSubscribers { + connection_id: self.connection_id, + }) + .await + } + ClientReceivedEvent::NoSubscriptionExisted => { + self.event_sender + .send(MqttEvent::NoSubscriptionExisted { + connection_id: self.connection_id, + }) + .await + } + } + Ok(()) + } +} + +async fn try_action<'a, C>( + current_connection_id: ConnectionId, + client: &mut C, + state: &RefCell, + connection_settings: &ConnectionSettings<'static>, + mut action: AimdbMqttAction, + is_retry: bool, +) -> Result<(), ClientError> +where + C: Client<'a>, +{ + if let Err(e) = action + .perform( + client, + connection_settings.client_id(), + current_connection_id, + is_retry, + ) + .await + { + state.borrow_mut().pending_action = Some(action); + return Err(e); + } + Ok(()) +} + +/// Connect, re-subscribe the inbound routes, then handle messages until an +/// error ends the session. Unlike the plain path (which queues subscriptions +/// once at startup), subscribing per session means inbound routing survives +/// reconnects. +#[allow(clippy::too_many_arguments)] +async fn handle_messages<'a, C>( + current_connection_id: ConnectionId, + client: &mut C, + state: &RefCell, + connection_settings: &ConnectionSettings<'static>, + topics: &[String], + event_sender: &Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>, + action_receiver: &mut Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>, + settings: &Settings, +) -> Result<(), Error> +where + C: Client<'a>, +{ + client.connect(connection_settings).await?; + + event_sender + .send(MqttEvent::Connected { + connection_id: current_connection_id, + }) + .await; + + for topic in topics { + client.subscribe(topic, QualityOfService::Qos1).await?; + } + + let mut connection_instant = Some(Instant::now()); + let mut last_ping_instant = Instant::now(); + + loop { + Timer::after(settings.poll_interval).await; + + if last_ping_instant.elapsed() > settings.ping_interval { + last_ping_instant = Instant::now(); + client.send_ping().await?; + } + + // Check for stabilisation + if let Some(instant) = connection_instant { + if instant.elapsed() > settings.stabilisation_interval { + connection_instant = None; + event_sender + .send(MqttEvent::ConnectionStable { + connection_id: current_connection_id, + }) + .await; + } + } + + // Check for too long since last connection event + let elapsed = state.borrow().last_connection_event.elapsed(); + if elapsed > settings.connection_event_max_interval { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT-TLS: server unresponsive"); + return Err(Error::MqttServerUnresponsive); + } + + // Poll with no delay while we have mqtt packets + while client.poll(false).await? {} + + // If we have a pending action, try to perform it + let pending_action = state.borrow_mut().pending_action.take(); + if let Some(action) = pending_action { + try_action( + current_connection_id, + client, + state, + connection_settings, + action, + true, + ) + .await?; + } + + // Handle actions from receiver + while let Ok(action) = action_receiver.try_receive() { + try_action( + current_connection_id, + client, + state, + connection_settings, + action, + false, + ) + .await?; + } + } +} diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 8660a49..8b6fe63 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -8,6 +8,8 @@ //! //! - `tokio-runtime`: Tokio-based connector using `rumqttc` //! - `embassy-runtime`: Embassy connector for embedded systems using `mountain-mqtt` +//! - `embassy-tls`: TLS (`mqtts://`), broker authentication, DNS, and the +//! SNTP time source for the Embassy connector (design 044) //! - `tracing`: Debug logging support (std) //! - `defmt`: Debug logging support (no_std) //! @@ -103,6 +105,17 @@ pub mod tokio_client; #[cfg(feature = "embassy-runtime")] pub mod embassy_client; +// SNTP wire codec β€” pure and feature-independent so it is unit-tested on the +// host; only the `embassy-tls` I/O task consumes it. +#[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] +pub(crate) mod sntp_codec; + +// TLS transport + SNTP time source for the Embassy client (design 044) +#[cfg(feature = "embassy-tls")] +pub mod embassy_tls; +#[cfg(feature = "embassy-tls")] +pub mod sntp; + // Re-export platform-specific types // Both implementations use MqttConnectorBuilder for API consistency // When both features are enabled (e.g., during testing), prefer tokio diff --git a/aimdb-mqtt-connector/src/sntp.rs b/aimdb-mqtt-connector/src/sntp.rs new file mode 100644 index 0000000..0a84720 --- /dev/null +++ b/aimdb-mqtt-connector/src/sntp.rs @@ -0,0 +1,125 @@ +//! SNTP time source for TLS certificate validation (design 044 Β§6). +//! +//! The reference boards have no battery-backed RTC, but checking a +//! certificate's validity window needs the current Unix time. This module +//! keeps one crate-global clock: Unix seconds at the `embassy_time` epoch +//! (boot), written after each SNTP sync and read through [`unix_now`] / +//! [`SntpClock`]. The TLS manager spawns [`run`] alongside its broker loop +//! and holds the first handshake until the first sync lands (design 044 D5). + +use core::sync::atomic::{AtomicU32, Ordering}; + +use embassy_net::dns::DnsQueryType; +use embassy_net::udp::{PacketMetadata, UdpSocket}; +use embassy_net::{IpEndpoint, Stack}; +use embassy_time::{with_timeout, Duration, Instant, Timer}; + +use crate::sntp_codec; + +/// Unix seconds at the `embassy_time` epoch; 0 = not yet synced. `u32` is +/// unambiguous until 2106 and stays a single atomic on Cortex-M (no 64-bit +/// atomics there). +static BOOT_UNIX_SECS: AtomicU32 = AtomicU32::new(0); + +/// NTP server port. +const SNTP_PORT: u16 = 123; +/// Fixed local port for the client socket (smoltcp cannot bind port 0). +const LOCAL_PORT: u16 = 55123; +/// How long to wait for a server reply before treating the sync as failed. +const REPLY_TIMEOUT: Duration = Duration::from_secs(5); +/// Retry cadence until the first successful sync (TLS is blocked on it). +const RETRY_INTERVAL: Duration = Duration::from_secs(10); +/// Re-sync cadence once synced β€” bounds clock drift to well under +/// certificate-validity granularity. +const RESYNC_INTERVAL: Duration = Duration::from_secs(3600); + +/// Current Unix time in seconds, or `None` before the first SNTP sync. +pub fn unix_now() -> Option { + let boot = BOOT_UNIX_SECS.load(Ordering::Relaxed); + if boot == 0 { + None + } else { + Some(u64::from(boot) + Instant::now().as_secs()) + } +} + +/// `embedded-tls` clock over the SNTP-synced time; `None` before the first +/// sync (the TLS manager never handshakes in that state, so certificate +/// validity is always actually checked). +pub struct SntpClock; + +impl embedded_tls::TlsClock for SntpClock { + fn now() -> Option { + unix_now() + } +} + +/// Keep the clock synced: query `server` until the first success, then +/// re-sync hourly. Runs forever; spawned by the TLS connector build. +pub(crate) async fn run(stack: Stack<'static>, server: &'static str) -> ! { + loop { + match sync_once(stack, server).await { + Ok(unix_secs) => { + let boot = unix_secs.saturating_sub(Instant::now().as_secs()); + BOOT_UNIX_SECS.store(boot as u32, Ordering::Relaxed); + #[cfg(feature = "defmt")] + defmt::info!("SNTP: synced, unix time {}", unix_secs); + Timer::after(RESYNC_INTERVAL).await; + } + Err(error) => { + #[cfg(feature = "defmt")] + defmt::warn!("SNTP: sync with {} failed: {}, retrying", server, error); + #[cfg(not(feature = "defmt"))] + let _ = error; + Timer::after(RETRY_INTERVAL).await; + } + } + } +} + +#[derive(Clone, Copy)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub(crate) enum SntpError { + /// DNS lookup failed or returned no A record. + Resolve, + /// The UDP socket could not be bound or the request not sent. + Send, + /// No reply within [`REPLY_TIMEOUT`]. + Timeout, + /// The reply was not a plausible SNTP server response. + InvalidReply, +} + +async fn sync_once(stack: Stack<'static>, server: &str) -> Result { + let addresses = stack + .dns_query(server, DnsQueryType::A) + .await + .map_err(|_| SntpError::Resolve)?; + let address = *addresses.first().ok_or(SntpError::Resolve)?; + + let mut rx_meta = [PacketMetadata::EMPTY; 2]; + let mut tx_meta = [PacketMetadata::EMPTY; 2]; + let mut rx_buffer = [0u8; sntp_codec::PACKET_SIZE * 2]; + let mut tx_buffer = [0u8; sntp_codec::PACKET_SIZE * 2]; + let mut socket = UdpSocket::new( + stack, + &mut rx_meta, + &mut rx_buffer, + &mut tx_meta, + &mut tx_buffer, + ); + socket.bind(LOCAL_PORT).map_err(|_| SntpError::Send)?; + + socket + .send_to(&sntp_codec::request(), IpEndpoint::new(address, SNTP_PORT)) + .await + .map_err(|_| SntpError::Send)?; + + let mut reply = [0u8; sntp_codec::PACKET_SIZE]; + let (len, _meta) = with_timeout(REPLY_TIMEOUT, socket.recv_from(&mut reply)) + .await + .map_err(|_| SntpError::Timeout)? + .map_err(|_| SntpError::InvalidReply)?; + + sntp_codec::parse_reply(&reply[..len]).ok_or(SntpError::InvalidReply) +} diff --git a/aimdb-mqtt-connector/src/sntp_codec.rs b/aimdb-mqtt-connector/src/sntp_codec.rs new file mode 100644 index 0000000..782a7e8 --- /dev/null +++ b/aimdb-mqtt-connector/src/sntp_codec.rs @@ -0,0 +1,92 @@ +//! SNTPv4 wire format (RFC 4330 subset) β€” pure encode/parse, no I/O. +//! +//! Feature-independent so the codec is unit-tested on the host; the Embassy +//! I/O task around it lives in [`sntp`](crate::sntp) (`embassy-tls` only). + +/// Seconds between the NTP epoch (1900-01-01) and the Unix epoch (1970-01-01). +const NTP_UNIX_OFFSET: u64 = 2_208_988_800; + +/// SNTP packets are exactly 48 bytes (no authenticator). +pub(crate) const PACKET_SIZE: usize = 48; + +/// A client request: LI = 0, VN = 4, Mode = 3 (client), everything else zero. +/// (RFC 4330 Β§5: a client may leave all timestamps zero and use only the +/// reply's transmit timestamp.) +pub(crate) fn request() -> [u8; PACKET_SIZE] { + let mut packet = [0u8; PACKET_SIZE]; + packet[0] = 0x23; // 00_100_011: LI 0, VN 4, Mode 3 + packet +} + +/// Parse a server reply into Unix seconds, or `None` if the packet is not a +/// plausible time: wrong size/mode, unsynchronized server (LI = 3), invalid +/// stratum (0 is a kiss-of-death, > 15 is reserved), or a zero/pre-Unix-epoch +/// transmit timestamp. Sub-second fraction is dropped β€” certificate validity +/// has day granularity. +/// +/// The 32-bit seconds field wraps in 2036 (NTP era 1); like every plain SNTP +/// consumer this treats all timestamps as era 0. +pub(crate) fn parse_reply(packet: &[u8]) -> Option { + if packet.len() < PACKET_SIZE { + return None; + } + let leap = packet[0] >> 6; + let mode = packet[0] & 0x07; + let stratum = packet[1]; + if leap == 3 || mode != 4 || stratum == 0 || stratum > 15 { + return None; + } + // Transmit timestamp, seconds part (bytes 40..44). + let ntp_secs = u64::from(u32::from_be_bytes([ + packet[40], packet[41], packet[42], packet[43], + ])); + if ntp_secs == 0 { + return None; + } + ntp_secs.checked_sub(NTP_UNIX_OFFSET) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reply_with(transmit_secs: u32) -> [u8; PACKET_SIZE] { + let mut packet = [0u8; PACKET_SIZE]; + packet[0] = 0x24; // LI 0, VN 4, Mode 4 (server) + packet[1] = 2; // stratum 2 + packet[40..44].copy_from_slice(&transmit_secs.to_be_bytes()); + packet + } + + #[test] + fn request_header() { + let packet = request(); + assert_eq!(packet[0], 0x23); + assert!(packet[1..].iter().all(|&b| b == 0)); + } + + #[test] + fn parses_valid_reply() { + // 2026-01-01T00:00:00Z = Unix 1767225600 = NTP 3976214400 + assert_eq!(parse_reply(&reply_with(3_976_214_400)), Some(1_767_225_600)); + } + + #[test] + fn rejects_bad_packets() { + assert_eq!(parse_reply(&[0u8; 40]), None); // truncated + let mut p = reply_with(3_976_214_400); + p[0] = 0x23; // mode 3 (client) echoed back + assert_eq!(parse_reply(&p), None); + let mut p = reply_with(3_976_214_400); + p[0] |= 0xC0; // LI 3: clock unsynchronized + assert_eq!(parse_reply(&p), None); + let mut p = reply_with(3_976_214_400); + p[1] = 0; // kiss-of-death + assert_eq!(parse_reply(&p), None); + let mut p = reply_with(3_976_214_400); + p[1] = 16; // reserved stratum + assert_eq!(parse_reply(&p), None); + assert_eq!(parse_reply(&reply_with(0)), None); // zero timestamp + assert_eq!(parse_reply(&reply_with(1)), None); // pre-Unix-epoch + } +} From db9c7f11acc8cc2a78f6eb3e5868561d88218a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 10 Jul 2026 21:38:01 +0000 Subject: [PATCH 03/11] fix(audit): update advisories to ignore known vulnerabilities in dependencies --- .cargo/audit.toml | 10 ++++++++-- deny.toml | 5 +++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 29e76b0..bbdfbd4 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -2,5 +2,11 @@ # See: https://github.com/rustsec/rustsec/blob/main/cargo-audit/audit.toml.example [advisories] -# Advisories to ignore (transitive dependencies we can't fix) -ignore = [] +# Advisories to ignore (transitive dependencies we can't fix in the current +# embedded and CLI dependency graph). These are tracked upstream and have no +# viable replacement in the workspace today. +ignore = [ + "RUSTSEC-2023-0071", # rsa timing sidechannel via embedded-tls; no fixed upgrade available + "RUSTSEC-2026-0110", # bare-metal deprecated via cortex-m (embedded-only transitive) + "RUSTSEC-2026-0173", # proc-macro-error2 unmaintained (transitive via defmt/tabled_derive) +] diff --git a/deny.toml b/deny.toml index 111663f..4b3e9fe 100644 --- a/deny.toml +++ b/deny.toml @@ -22,9 +22,10 @@ wildcards = "allow" [advisories] version = 2 yanked = "warn" -# bare-metal is archived/deprecated and pulled transitively by cortex-m in the -# embedded stack; there is currently no drop-in replacement at this depth. +# These transitive advisories are accepted for the current workspace because the +# upstream dependency chain has no viable replacement path at the moment. ignore = [ + "RUSTSEC-2023-0071", # rsa timing sidechannel via embedded-tls; no fixed upgrade available "RUSTSEC-2026-0110", # bare-metal deprecated via cortex-m (embedded-only transitive) "RUSTSEC-2026-0173", # TEMP: proc-macro-error2 unmaintained (transitive via defmt-macros/tabled_derive) ] From d443c9d15c64b93cdb4a53d274d32709332ac943 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 10 Jul 2026 22:00:11 +0000 Subject: [PATCH 04/11] refactor: remove design references from mqtt-connector doc comments --- aimdb-mqtt-connector/src/embassy_client.rs | 18 +++++++++--------- aimdb-mqtt-connector/src/embassy_tls.rs | 22 +++++++++++----------- aimdb-mqtt-connector/src/lib.rs | 4 ++-- aimdb-mqtt-connector/src/sntp.rs | 4 ++-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 9c4d023..7dc4bec 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -297,9 +297,9 @@ unsafe impl Sync for TlsSlot {} /// /// Collects routes from the database during `build()` and wires the broker /// manager + the outbound/inbound pumps. The broker URL scheme selects the -/// transport (design 044 D9): `mqtt://` is plain TCP (default port 1883), -/// `mqtts://` is TLS (default port 8883) and requires both the `embassy-tls` -/// feature and [`with_tls`](Self::with_tls). +/// transport: `mqtt://` is plain TCP (default port 1883), `mqtts://` is TLS +/// (default port 8883) and requires both the `embassy-tls` feature and +/// [`with_tls`](Self::with_tls). pub struct MqttConnectorBuilder { broker_url: String, client_id: String, @@ -351,7 +351,7 @@ impl MqttConnectorBuilder { self } - /// Provide the TLS materials for an `mqtts://` broker (design 044 Β§4). + /// Provide the TLS materials for an `mqtts://` broker. /// /// Required for `mqtts://` URLs; rejected at `build()` for `mqtt://`. #[cfg(feature = "embassy-tls")] @@ -393,7 +393,7 @@ impl ConnectorBuilder for MqttConnectorBuilder { static_connection_settings(&self.client_id, self.credentials.as_ref()); // Broker manager task(s) + the channel ends for the pumps. - // The URL scheme selects the transport (design 044 D9). + // The URL scheme selects the transport. #[cfg(feature = "embassy-tls")] let (action_sender, event_receiver, manager_tasks) = { let tls_options = self.tls.0.borrow_mut().take(); @@ -454,7 +454,7 @@ impl ConnectorBuilder for MqttConnectorBuilder { } } -/// Parsed broker endpoint: transport + authority (design 044 D9). +/// Parsed broker endpoint: transport + authority. struct BrokerUrl { tls: bool, host: String, @@ -597,8 +597,8 @@ fn setup_manager( } /// Set up the TLS broker manager ([`run_tls`]) plus the SNTP time-source -/// task (design 044 Β§5–§6). Synchronous β€” no `.await` β€” so the caller's -/// `build` future stays `Send`. +/// task. Synchronous β€” no `.await` β€” so the caller's `build` future stays +/// `Send`. #[cfg(feature = "embassy-tls")] fn setup_tls_manager( broker: &BrokerUrl, @@ -616,7 +616,7 @@ fn setup_tls_manager( let (action_sender, action_receiver, event_sender, event_receiver) = init_channels(); // `Settings` supplies the session cadence and port; its address field is - // unused on the TLS path (the host is resolved per attempt, 044 D7). + // unused on the TLS path (the host is resolved per attempt instead). let settings = Settings::new(Ipv4Address::UNSPECIFIED, broker.port); let network = stack.get(); let host = broker.host.clone(); diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embassy_tls.rs index 736deac..9d4d45d 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embassy_tls.rs @@ -1,4 +1,4 @@ -//! TLS transport for the Embassy MQTT client (design 044). +//! TLS transport for the Embassy MQTT client. //! //! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the Embassy //! TCP socket, wrapped in mountain-mqtt's [`ConnectionEmbedded`] so the MQTT @@ -10,8 +10,8 @@ //! The session loop ([`handle_messages`], [`State`], [`ChannelEventHandler`]) //! is a port of mountain-mqtt-embassy's private equivalents (MIT OR //! Apache-2.0): upstream `run()` constructs a bare `TcpSocket` internally and -//! offers no seam for a TLS session (design 044 D6). Keep the loop in sync -//! with the fork when bumping it. +//! offers no seam for a TLS session. Keep the loop in sync with the fork when +//! bumping it. use alloc::string::String; use alloc::vec::Vec; @@ -53,7 +53,7 @@ use crate::sntp::{self, SntpClock}; /// covers RSA-4096 leaves with headroom. const CERT_BUFFER_SIZE: usize = 4096; -/// TLS materials for a `mqtts://` broker connection (design 044 Β§4). +/// TLS materials for a `mqtts://` broker connection. /// /// All references are `'static`: the session outlives `build()`, so buffers /// and the RNG live in `StaticCell`s (or equivalents) owned by the @@ -155,10 +155,10 @@ impl embedded_io_async::Write for SharedTcp<'_, '_> { /// undecrypted bytes on the wire (`can_recv` on the shared socket). Checking /// both keeps coalesced packets flowing promptly. /// -/// Known limitation (documented in design 044): wire bytes that decrypt to -/// *no* application data (unsolicited session tickets, KeyUpdate) make -/// `receive` wait for the next real record; if the broker stays silent, the -/// keep-alive lapse tears the session down and the manager reconnects. +/// Known limitation: wire bytes that decrypt to *no* application data +/// (unsolicited session tickets, KeyUpdate) make `receive` wait for the next +/// real record; if the broker stays silent, the keep-alive lapse tears the +/// session down and the manager reconnects. struct TlsSession<'r, 'a, 'b> { tls: TlsConnection<'b, SharedTcp<'r, 'a>, Aes128GcmSha256>, socket: SharedTcp<'r, 'a>, @@ -204,7 +204,7 @@ impl Connection for TlsSession<'_, '_, '_> { /// [`CryptoProvider`] pairing the injected TRNG with `rustpki` certificate /// verification (time from [`SntpClock`]). Client-certificate signing is /// deliberately absent β€” the mesh authenticates with MQTT credentials -/// (design 044 non-goals). +/// instead. struct TrngProvider<'a> { rng: &'a mut dyn CryptoRngCore, verifier: CertVerifier<'static, Aes128GcmSha256, SntpClock, CERT_BUFFER_SIZE>, @@ -227,7 +227,7 @@ impl CryptoProvider for TrngProvider<'_> { /// The TLS broker manager: resolve β†’ TCP β†’ TLS handshake β†’ MQTT session, /// reconnecting forever with the same [`Settings`] cadence as the plain /// path's `mqtt_manager::run` (`settings.address` is unused β€” the TLS path -/// resolves `host` per attempt, design 044 D7). +/// resolves `host` per attempt instead). #[allow(clippy::too_many_arguments)] pub(crate) async fn run_tls( stack: Stack<'static>, @@ -255,7 +255,7 @@ pub(crate) async fn run_tls( loop { // Certificate validity needs real time β€” hold the first handshake - // until SNTP has synced (design 044 D5). + // until SNTP has synced. if sntp::unix_now().is_none() { #[cfg(feature = "defmt")] defmt::info!("MQTT-TLS: waiting for SNTP time sync..."); diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 8b6fe63..6e8c064 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -9,7 +9,7 @@ //! - `tokio-runtime`: Tokio-based connector using `rumqttc` //! - `embassy-runtime`: Embassy connector for embedded systems using `mountain-mqtt` //! - `embassy-tls`: TLS (`mqtts://`), broker authentication, DNS, and the -//! SNTP time source for the Embassy connector (design 044) +//! SNTP time source for the Embassy connector //! - `tracing`: Debug logging support (std) //! - `defmt`: Debug logging support (no_std) //! @@ -110,7 +110,7 @@ pub mod embassy_client; #[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] pub(crate) mod sntp_codec; -// TLS transport + SNTP time source for the Embassy client (design 044) +// TLS transport + SNTP time source for the Embassy client #[cfg(feature = "embassy-tls")] pub mod embassy_tls; #[cfg(feature = "embassy-tls")] diff --git a/aimdb-mqtt-connector/src/sntp.rs b/aimdb-mqtt-connector/src/sntp.rs index 0a84720..cfd8da1 100644 --- a/aimdb-mqtt-connector/src/sntp.rs +++ b/aimdb-mqtt-connector/src/sntp.rs @@ -1,11 +1,11 @@ -//! SNTP time source for TLS certificate validation (design 044 Β§6). +//! SNTP time source for TLS certificate validation. //! //! The reference boards have no battery-backed RTC, but checking a //! certificate's validity window needs the current Unix time. This module //! keeps one crate-global clock: Unix seconds at the `embassy_time` epoch //! (boot), written after each SNTP sync and read through [`unix_now`] / //! [`SntpClock`]. The TLS manager spawns [`run`] alongside its broker loop -//! and holds the first handshake until the first sync lands (design 044 D5). +//! and holds the first handshake until the first sync lands. use core::sync::atomic::{AtomicU32, Ordering}; From c9c9f906354ecddd7003a0797ed840914d79772e Mon Sep 17 00:00:00 2001 From: test Date: Fri, 10 Jul 2026 22:18:49 +0000 Subject: [PATCH 05/11] chore: remove unused dependencies from Cargo.lock and deny.toml --- Cargo.lock | 1 - deny.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d1b6ec..48c4c4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4962,7 +4962,6 @@ dependencies = [ "aimdb-data-contracts", "aimdb-embassy-adapter", "aimdb-mqtt-connector", - "base64 0.22.1", "cortex-m", "cortex-m-rt", "critical-section", diff --git a/deny.toml b/deny.toml index 4b3e9fe..2ea3316 100644 --- a/deny.toml +++ b/deny.toml @@ -25,7 +25,6 @@ yanked = "warn" # These transitive advisories are accepted for the current workspace because the # upstream dependency chain has no viable replacement path at the moment. ignore = [ - "RUSTSEC-2023-0071", # rsa timing sidechannel via embedded-tls; no fixed upgrade available "RUSTSEC-2026-0110", # bare-metal deprecated via cortex-m (embedded-only transitive) "RUSTSEC-2026-0173", # TEMP: proc-macro-error2 unmaintained (transitive via defmt-macros/tabled_derive) ] From 77958fae8b4c6fe86950b93449b1476d21069b6b Mon Sep 17 00:00:00 2001 From: test Date: Fri, 10 Jul 2026 22:52:05 +0000 Subject: [PATCH 06/11] Implement TLS support in the Embassy MQTT connector - Added `embassy_tls` feature to enable TLS connections using `embedded-tls`. - Introduced `TlsOptions` for configuring TLS parameters, including read and write buffers. - Updated the MQTT connection logic to handle both plain and TLS connections based on the feature flag. - Implemented SNTP for time synchronization to ensure certificate validity during TLS handshakes. - Enhanced error handling for IP literals in broker URLs, rejecting IPv6 literals and warning for IPv4 literals. - Updated documentation and examples to reflect the new TLS capabilities and usage instructions. --- aimdb-mqtt-connector/src/embassy_client.rs | 22 +++- aimdb-mqtt-connector/src/embassy_tls.rs | 30 +++-- aimdb-mqtt-connector/src/sntp.rs | 64 ++++++++-- aimdb-mqtt-connector/src/sntp_codec.rs | 66 +++++++---- docs/design/044-embassy-mqtt-tls.md | 107 ++++++++++------- .../embassy-mqtt-connector-demo/.gitignore | 2 + .../embassy-mqtt-connector-demo/Cargo.toml | 3 + .../embassy-mqtt-connector-demo/README.md | 29 +++++ .../embassy-mqtt-connector-demo/src/main.rs | 112 +++++++++++++++--- 9 files changed, 332 insertions(+), 103 deletions(-) diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 7dc4bec..e58e83d 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -74,7 +74,7 @@ use mountain_mqtt_embassy::mqtt_manager::{self, MqttEvent, Settings}; #[cfg(feature = "embassy-tls")] pub use crate::embassy_tls::TlsOptions; #[cfg(feature = "embassy-tls")] -use crate::embassy_tls::{host_is_ip_literal, run_tls}; +use crate::embassy_tls::{host_ip_literal, run_tls, READ_BUF_MIN}; /// Maximum number of pending MQTT actions and events pub(crate) const CHANNEL_SIZE: usize = 32; @@ -607,9 +607,25 @@ fn setup_tls_manager( stack: aimdb_embassy_adapter::connectors::NetStack, topics: Vec, ) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> { - if host_is_ip_literal(&broker.host) { + match host_ip_literal(&broker.host) { + Some(core::net::IpAddr::V6(_)) => { + return Err(build_err( + "mqtts:// with an IPv6 literal can never pass certificate verification β€” use a hostname", + )); + } + Some(core::net::IpAddr::V4(_)) => { + // Verifies only via the certificate's CN β€” private-CA bench + // setups pin the IP there; public CAs won't issue such certs. + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT-TLS: broker host is an IP literal; the certificate must carry it in CN β€” prefer a hostname" + ); + } + None => {} + } + if options.read_buf.len() < READ_BUF_MIN { return Err(build_err( - "mqtts:// needs a hostname β€” certificate verification cannot match an IP literal", + "TLS read buffer too small β€” a TLS 1.3 peer may send 16 KB records; provide at least 16 640 bytes", )); } diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embassy_tls.rs index 9d4d45d..f3318c1 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embassy_tls.rs @@ -16,7 +16,7 @@ use alloc::string::String; use alloc::vec::Vec; use core::cell::RefCell; -use core::net::Ipv4Addr; +use core::net::IpAddr; use embassy_net::dns::DnsQueryType; use embassy_net::tcp::TcpSocket; @@ -53,6 +53,13 @@ use crate::sntp::{self, SntpClock}; /// covers RSA-4096 leaves with headroom. const CERT_BUFFER_SIZE: usize = 4096; +/// Minimum TLS record read buffer: a TLS 1.3 peer may send full-size records +/// (2^14 payload + record overhead) regardless of our `max_fragment_length` +/// offer, and `embedded-tls` fails any record larger than the buffer β€” a +/// smaller buffer works until the first big record, then reconnect-loops. +/// Enforced at `build()` so undersizing fails loudly instead. +pub(crate) const READ_BUF_MIN: usize = 16_640; + /// TLS materials for a `mqtts://` broker connection. /// /// All references are `'static`: the session outlives `build()`, so buffers @@ -71,9 +78,9 @@ impl TlsOptions { /// /// * `rng` β€” CSPRNG for the handshake; on STM32 the hardware TRNG /// (`embassy_stm32::rng::Rng` implements `CryptoRngCore`). - /// * `read_buf` β€” TLS record read buffer. 16 640 bytes recommended: a + /// * `read_buf` β€” TLS record read buffer. At least 16 640 bytes (a /// TLS 1.3 peer may send full-size records regardless of our - /// `max_fragment_length` offer. + /// `max_fragment_length` offer); `build()` rejects smaller buffers. /// * `write_buf` β€” TLS record write buffer; 4 096 bytes is plenty for /// MQTT-sized writes. pub fn new( @@ -381,10 +388,19 @@ async fn resolve(stack: Stack<'static>, host: &str) -> Option { } } -/// SNI requires a name, not an address β€” used by `build()` to reject broker -/// URLs that can never verify. -pub(crate) fn host_is_ip_literal(host: &str) -> bool { - host.parse::().is_ok() +/// Parse the broker host as an IP literal (with or without URL-style +/// brackets, `[::1]`). `build()` uses this to vet `mqtts://` hosts: +/// certificate verification prefers a DNS name, but an IPv4 literal can +/// still pass through `rustpki`'s CN fallback when a private CA pins the +/// dotted quad there (the dev bench does) β€” allowed with a warning. An IPv6 +/// literal can never match (the verifier's hostname charset has no `:`) and +/// is rejected. +pub(crate) fn host_ip_literal(host: &str) -> Option { + let host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + host.parse::().ok() } // --- Ported session loop (see module doc) --------------------------------- diff --git a/aimdb-mqtt-connector/src/sntp.rs b/aimdb-mqtt-connector/src/sntp.rs index cfd8da1..cdd28bf 100644 --- a/aimdb-mqtt-connector/src/sntp.rs +++ b/aimdb-mqtt-connector/src/sntp.rs @@ -23,8 +23,11 @@ static BOOT_UNIX_SECS: AtomicU32 = AtomicU32::new(0); /// NTP server port. const SNTP_PORT: u16 = 123; -/// Fixed local port for the client socket (smoltcp cannot bind port 0). -const LOCAL_PORT: u16 = 55123; +/// Local ephemeral-port range for the client socket. smoltcp cannot bind +/// port 0, so "random source port" is randomized here per attempt β€” a reply +/// must land on the right port *and* echo the request nonce to be accepted. +const LOCAL_PORT_BASE: u16 = 49152; +const LOCAL_PORT_SPAN: u16 = 16384; /// How long to wait for a server reply before treating the sync as failed. const REPLY_TIMEOUT: Duration = Duration::from_secs(5); /// Retry cadence until the first successful sync (TLS is blocked on it). @@ -59,12 +62,27 @@ impl embedded_tls::TlsClock for SntpClock { pub(crate) async fn run(stack: Stack<'static>, server: &'static str) -> ! { loop { match sync_once(stack, server).await { + // 0 is the "unsynced" sentinel and anything above u32::MAX would + // truncate (year 2106): store neither β€” treat the sync as failed + // rather than poisoning the clock and sleeping an hour on it. Ok(unix_secs) => { - let boot = unix_secs.saturating_sub(Instant::now().as_secs()); - BOOT_UNIX_SECS.store(boot as u32, Ordering::Relaxed); - #[cfg(feature = "defmt")] - defmt::info!("SNTP: synced, unix time {}", unix_secs); - Timer::after(RESYNC_INTERVAL).await; + match u32::try_from(unix_secs.saturating_sub(Instant::now().as_secs())) { + Ok(boot @ 1..) => { + BOOT_UNIX_SECS.store(boot, Ordering::Relaxed); + #[cfg(feature = "defmt")] + defmt::info!("SNTP: synced, unix time {}", unix_secs); + Timer::after(RESYNC_INTERVAL).await; + } + _ => { + #[cfg(feature = "defmt")] + defmt::warn!( + "SNTP: implausible time {} from {}, retrying", + unix_secs, + server + ); + Timer::after(RETRY_INTERVAL).await; + } + } } Err(error) => { #[cfg(feature = "defmt")] @@ -90,12 +108,30 @@ pub(crate) enum SntpError { InvalidReply, } +/// Best-effort request nonce: the hardware TRNG belongs to the TLS session +/// (injected via `TlsOptions`), so unpredictability comes from the tick +/// counter through a splitmix64 finalizer. Enough to defeat *blind* reply +/// spoofing β€” an off-path attacker cannot observe when the request fired β€” +/// while an on-path attacker defeats unauthenticated NTP regardless. +fn request_nonce() -> u64 { + let mut z = Instant::now() + .as_ticks() + .wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + async fn sync_once(stack: Stack<'static>, server: &str) -> Result { let addresses = stack .dns_query(server, DnsQueryType::A) .await .map_err(|_| SntpError::Resolve)?; let address = *addresses.first().ok_or(SntpError::Resolve)?; + let server_endpoint = IpEndpoint::new(address, SNTP_PORT); + + let nonce = request_nonce(); + let local_port = LOCAL_PORT_BASE + (nonce >> 48) as u16 % LOCAL_PORT_SPAN; let mut rx_meta = [PacketMetadata::EMPTY; 2]; let mut tx_meta = [PacketMetadata::EMPTY; 2]; @@ -108,18 +144,24 @@ async fn sync_once(stack: Stack<'static>, server: &str) -> Result [u8; PACKET_SIZE] { +/// Replies mapping below this Unix time (2025-01-01T00:00:00Z) are rejected +/// as implausible: everything this clock helps validate postdates it, and a +/// "successful" sync to a bogus early time would poison certificate checks. +pub(crate) const MIN_UNIX_SECS: u64 = 1_735_689_600; + +/// A client request: LI = 0, VN = 4, Mode = 3 (client); `nonce` rides in the +/// transmit-timestamp field. (RFC 4330 Β§5: the server echoes that field back +/// in the reply's originate timestamp, correlating reply to request.) +pub(crate) fn request(nonce: u64) -> [u8; PACKET_SIZE] { let mut packet = [0u8; PACKET_SIZE]; packet[0] = 0x23; // 00_100_011: LI 0, VN 4, Mode 3 + packet[40..48].copy_from_slice(&nonce.to_be_bytes()); packet } /// Parse a server reply into Unix seconds, or `None` if the packet is not a -/// plausible time: wrong size/mode, unsynchronized server (LI = 3), invalid -/// stratum (0 is a kiss-of-death, > 15 is reserved), or a zero/pre-Unix-epoch -/// transmit timestamp. Sub-second fraction is dropped β€” certificate validity +/// plausible answer to our request: wrong size/mode, originate timestamp not +/// echoing the request `nonce` (rejects blind spoofs and stale replies from +/// earlier attempts), unsynchronized server (LI = 3), invalid stratum (0 is a +/// kiss-of-death, > 15 is reserved), or a transmit timestamp before +/// [`MIN_UNIX_SECS`]. Sub-second fraction is dropped β€” certificate validity /// has day granularity. /// /// The 32-bit seconds field wraps in 2036 (NTP era 1); like every plain SNTP /// consumer this treats all timestamps as era 0. -pub(crate) fn parse_reply(packet: &[u8]) -> Option { +pub(crate) fn parse_reply(packet: &[u8], nonce: u64) -> Option { if packet.len() < PACKET_SIZE { return None; } @@ -36,57 +44,71 @@ pub(crate) fn parse_reply(packet: &[u8]) -> Option { if leap == 3 || mode != 4 || stratum == 0 || stratum > 15 { return None; } + // Originate timestamp (bytes 24..32) must echo the request's transmit + // timestamp β€” our nonce. + if packet[24..32] != nonce.to_be_bytes() { + return None; + } // Transmit timestamp, seconds part (bytes 40..44). let ntp_secs = u64::from(u32::from_be_bytes([ packet[40], packet[41], packet[42], packet[43], ])); - if ntp_secs == 0 { - return None; - } - ntp_secs.checked_sub(NTP_UNIX_OFFSET) + let unix_secs = ntp_secs.checked_sub(NTP_UNIX_OFFSET)?; + (unix_secs >= MIN_UNIX_SECS).then_some(unix_secs) } #[cfg(test)] mod tests { use super::*; + const NONCE: u64 = 0x1122_3344_5566_7788; + fn reply_with(transmit_secs: u32) -> [u8; PACKET_SIZE] { let mut packet = [0u8; PACKET_SIZE]; packet[0] = 0x24; // LI 0, VN 4, Mode 4 (server) packet[1] = 2; // stratum 2 + packet[24..32].copy_from_slice(&NONCE.to_be_bytes()); // originate = echoed nonce packet[40..44].copy_from_slice(&transmit_secs.to_be_bytes()); packet } #[test] fn request_header() { - let packet = request(); + let packet = request(NONCE); assert_eq!(packet[0], 0x23); - assert!(packet[1..].iter().all(|&b| b == 0)); + assert!(packet[1..40].iter().all(|&b| b == 0)); + assert_eq!(packet[40..48], NONCE.to_be_bytes()); // transmit = nonce } #[test] fn parses_valid_reply() { // 2026-01-01T00:00:00Z = Unix 1767225600 = NTP 3976214400 - assert_eq!(parse_reply(&reply_with(3_976_214_400)), Some(1_767_225_600)); + assert_eq!( + parse_reply(&reply_with(3_976_214_400), NONCE), + Some(1_767_225_600) + ); } #[test] fn rejects_bad_packets() { - assert_eq!(parse_reply(&[0u8; 40]), None); // truncated + assert_eq!(parse_reply(&[0u8; 40], NONCE), None); // truncated let mut p = reply_with(3_976_214_400); p[0] = 0x23; // mode 3 (client) echoed back - assert_eq!(parse_reply(&p), None); + assert_eq!(parse_reply(&p, NONCE), None); let mut p = reply_with(3_976_214_400); p[0] |= 0xC0; // LI 3: clock unsynchronized - assert_eq!(parse_reply(&p), None); + assert_eq!(parse_reply(&p, NONCE), None); let mut p = reply_with(3_976_214_400); p[1] = 0; // kiss-of-death - assert_eq!(parse_reply(&p), None); + assert_eq!(parse_reply(&p, NONCE), None); let mut p = reply_with(3_976_214_400); p[1] = 16; // reserved stratum - assert_eq!(parse_reply(&p), None); - assert_eq!(parse_reply(&reply_with(0)), None); // zero timestamp - assert_eq!(parse_reply(&reply_with(1)), None); // pre-Unix-epoch + assert_eq!(parse_reply(&p, NONCE), None); + // wrong nonce: blind spoof or stale reply from an earlier attempt + assert_eq!(parse_reply(&reply_with(3_976_214_400), NONCE ^ 1), None); + assert_eq!(parse_reply(&reply_with(0), NONCE), None); // zero timestamp + assert_eq!(parse_reply(&reply_with(1), NONCE), None); // pre-Unix-epoch + // pre-2025 (NTP for 2020-01-01): below the plausibility floor + assert_eq!(parse_reply(&reply_with(3_786_825_600), NONCE), None); } } diff --git a/docs/design/044-embassy-mqtt-tls.md b/docs/design/044-embassy-mqtt-tls.md index 942b7d8..cacadde 100644 --- a/docs/design/044-embassy-mqtt-tls.md +++ b/docs/design/044-embassy-mqtt-tls.md @@ -1,19 +1,20 @@ # 044 β€” Embassy MQTT client: TLS -**Status:** 🚧 Implemented on `feat/embassy-mqtt-tls` (hardware verification pending) +**Status:** 🚧 Implemented on `feat/embassy-mqtt-tls-connector` (hardware verification pending) **Scope:** the `embassy-tls` feature of [`aimdb-mqtt-connector`](../../aimdb-mqtt-connector) β€” TLS, broker authentication, DNS resolution, and an SNTP time source for the Embassy MQTT -client β€” plus the `weather-station-gamma` wiring that consumes it. Resolves -the open problem in [042 Β§8.1](./042-public-weather-mesh-flagship.md) -(option 1, recommended there) and issue `000-wp7-embassy-mqtt-tls`. +client β€” plus the `embassy-mqtt-connector-demo` wiring that consumes it. +Resolves the open problem in +[042 Β§8.1](./042-public-weather-mesh-flagship.md) (option 1, recommended +there) and issue `000-wp7-embassy-mqtt-tls`. -**Consumers:** `weather-station-gamma` -([`examples/weather-mesh-demo`](../../examples/weather-mesh-demo)) today; -the MCU station template (issue 006, `aimdb-weather-mesh` repo) once it -tracks this. The hub/cloud path already has TLS via the Tokio client -(`rumqttc`, `use-native-tls`) and is untouched. +**Consumers:** [`examples/embassy-mqtt-connector-demo`](../../examples/embassy-mqtt-connector-demo) +(`tls` feature) today; `weather-station-gamma` and the MCU station template +(issue 006, `aimdb-weather-mesh` repo) once they track this. The hub/cloud +path already has TLS via the Tokio client (`rumqttc`, `use-native-tls`) and +is untouched. --- @@ -46,16 +47,16 @@ client also needs DNS resolution. | # | Decision | Rationale | |---|---|---| -| 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. | +| 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 `Connection` seam unchanged. TLS 1.3-only is acceptable: EMQX supports it, and this client exists for the flagship path. | | 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. | | 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. | | 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. | | 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. | -| 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. | -| 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. | +| 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`) keeps the fork delta minimal and honours the WP7 acceptance criterion that the local plain-TCP demo path is untouched. | +| 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 **IPv6 literal is rejected at `build()`**, an IPv4 literal is allowed with a defmt warning. | Serverless brokers are DNS names and records can change between reconnects. `rustpki` matches SAN DNS names and falls back to the CN, so an IPv4 literal verifies when a private CA pins the dotted quad in the CN (the `dev/mosquitto` bench does; public CAs won't issue such certs). An IPv6 literal can never match β€” the matcher's hostname charset has no `:` β€” and failing loudly at build beats a forever-retrying handshake on a headless board. The plain path stays IPv4-literal-only: unchanged is the contract. | | 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://`. | | 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. | -| 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. | +| D10 | TLS record buffers are **application-provided** (`&'static mut [u8]` in `TlsOptions`; 16 640 read minimum β€” enforced at `build()` β€” and 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); an undersized one works until the first big record, then reconnect-loops, so `build()` rejects it loudly. Hiding the buffers inside the connector would bloat every embassy-mqtt user and take sizing away from the one party that knows the board. | ## 3. Non-goals @@ -66,11 +67,10 @@ client also needs DNS resolution. at weather cadence that is fine. - **TLS or DNS for the plain-TCP path** β€” unchanged by design (D6, D7). - **Tokio client changes** β€” `mqtts://` already works there. -- **Removing gamma's/the template's `mqtt://`-only guard sight unseen** β€” - gamma's guard is lifted here because the same commit wires the TLS it - guards against; the *template's* guard (WP7 scope item 3) falls only after - issue 006 lands and an end-to-end `mqtts://` profile run passes on - hardware. +- **Removing gamma's/the template's `mqtt://`-only guard** β€” both stay + until their own TLS wiring lands (issue 006 for the template, plus an + end-to-end `mqtts://` profile run on hardware); this design wires the + connector and the `embassy-mqtt-connector-demo` consumer only. ## 4. Connector API @@ -87,8 +87,8 @@ let mqtt = MqttConnectorBuilder::new("mqtts://xxxx.eu-central-1.emqx.cloud:8883" .with_tls(TlsOptions::new( TLS_RNG.init(rng), // &'static mut dyn CryptoRngCore (D3) CA_DER, // &'static [u8], root CA, DER (D2) - TLS_READ_BUF.init([0; 16_640]), - TLS_WRITE_BUF.init([0; 4_096]), + TLS_READ_BUF.init_with(|| [0; 16_640]), // init_with: keeps 16 KB off the stack + TLS_WRITE_BUF.init_with(|| [0; 4_096]), )); ``` @@ -113,8 +113,11 @@ falling through to the reconnect delay (same policy as upstream `run()`): a `CryptoProvider` combining the injected RNG with `rustpki::CertVerifier` over the embedded CA (D2, D3). -5. **MQTT session** β€” `ConnectionEmbedded::new(tls_connection)` into - `ClientNoQueue`, then the ported loop: CONNECT with credentials (D8), +5. **MQTT session** β€” a connector-local `TlsSession` implements + mountain-mqtt's `Connection` over the open TLS session + (`ConnectionEmbedded` needs `ReadReady`, which a TLS session cannot + offer) into `ClientNoQueue`, then the ported loop: CONNECT with + credentials (D8), re-subscribe the inbound routes (per session, so inbound routing survives reconnects β€” the plain path queues subscriptions only once at startup), then ping/poll/action-drain with the same `Settings` timeouts, @@ -130,9 +133,15 @@ of the connector. Minimal SNTPv4 client (RFC 4330 subset), `embassy-tls`-gated: -- one UDP socket, one 48-byte request (`LI=0, VN=4, Mode=client`), transmit - timestamp taken from the reply, sanity-checked (`Mode=server`, - `stratum 1..=15`, non-zero timestamp); +- one UDP socket per attempt on a randomized local port, one 48-byte request + (`LI=0, VN=4, Mode=client`) carrying a nonce in its transmit timestamp + (mixed from the tick counter β€” the TRNG belongs to the TLS session); the + reply must come from the queried endpoint, echo the nonce in its originate + timestamp (RFC 4330 Β§5 β€” rejects blind off-path spoofs and stale replies; + an on-path attacker defeats unauthenticated NTP regardless), and pass + sanity checks (`Mode=server`, `stratum 1..=15`, transmit timestamp β‰₯ + 2025-01-01 β€” accepting a bogus early time would poison certificate + checks); - global state is a single `AtomicU32` β€” Unix seconds minus `embassy_time::Instant::now().as_secs()` at sync β€” read by `unix_now() -> Option` and the `SntpClock: TlsClock` impl; @@ -145,20 +154,26 @@ has day granularity, and the representation is unambiguous until 2106. format shared by every SNTP consumer; a future era-aware fix is contained in one parse function.) -## 7. Gamma wiring - -- **`build.rs`**: the `mqtt://`-only panic is replaced by real `mqtts://` - handling β€” default port 8883, `cargo:rustc-cfg=mesh_tls`, and CA embedding: - `MESH_CA` (env var, path to the root CA in PEM or DER) is decoded to DER in - `OUT_DIR/ca.der` and surfaced as `MQTT_CA_DER`. A `mqtts://` profile - without `MESH_CA` is a build error with instructions. Plain profiles and - the no-profile local demo generate byte-identical config to today. -- **`main.rs`**: TLS statics (RNG cell, record buffers) and the - `.with_credentials(...)`/`.with_tls(...)` calls sit behind - `#[cfg(mesh_tls)]`, so a plain-demo build carries zero TLS code or RAM β€” - the same compile-time sim-to-real philosophy as design 041. The TRNG - moves into a `StaticCell` so the one instance seeds the net stack *and* - feeds TLS. `StackResources` grows 3 β†’ 5 (DNS + SNTP sockets). +## 7. Demo wiring (`embassy-mqtt-connector-demo`) + +Behind a `tls` cargo feature, so a plain build carries zero TLS code or +RAM β€” the same compile-time philosophy as design 041: + +- **Broker config**: `MQTT_BROKER_HOST` (a DNS name) and + `MQTT_BROKER_TLS_PORT` consts replace the plain path's IP/port; + `MQTT_CREDENTIALS: Option<(&str, &str)>` feeds `.with_credentials(...)` + when set. The root CA embeds from `ca.der` next to `Cargo.toml` + (`include_bytes!`, DER; the README shows the one-line PEMβ†’DER + conversion, and the file is gitignored). +- **`main.rs`**: the TLS statics (RNG cell, record buffers via `init_with` + so the 16 KB array never transits the stack) and the `.with_tls(...)` + call sit behind `#[cfg(feature = "tls")]`. The TRNG moves into a + `StaticCell` so the one instance seeds the net stack *and* feeds TLS. + `StackResources` grows 3 β†’ 5 (DNS + SNTP sockets). + +The `weather-station-gamma`/station-template wiring (profile-driven CA +embedding in `build.rs`) is future work β€” it follows once issue 006 and the +EMQX credential (issue 007) land. ## 8. Cost @@ -188,9 +203,11 @@ its broker CA chain to P-256. - Host: existing `make check`/`make test` legs, plus unit tests for the URL scheme/port/TLS-option validation and the SNTP packet codec. - Cross: the existing thumbv7em clippy leg gains an - `embassy-runtime,embassy-tls,defmt` variant; gamma builds for - thumbv8m.main with a synthetic `mqtts://` profile + `MESH_CA` (compile - proof of the full wiring) and without one (plain demo unchanged). -- Hardware (the WP7 acceptance): gamma + real mesh profile connects, - authenticates, publishes through the TLS broker β€” runs on the bench once - a broker credential exists (issue 007), tracked in the WP7 issue. + `embassy-runtime,embassy-tls,defmt` variant; + `embassy-mqtt-connector-demo` builds with `--features tls` + a local + `ca.der` (compile proof of the full wiring) and without (plain demo + unchanged). +- Hardware (the WP7 acceptance): the demo β€” later gamma + a real mesh + profile β€” connects, authenticates, publishes through a TLS broker; runs + on the bench once a broker credential exists (issue 007), tracked in the + WP7 issue. diff --git a/examples/embassy-mqtt-connector-demo/.gitignore b/examples/embassy-mqtt-connector-demo/.gitignore index 8112e1e..946aad7 100644 --- a/examples/embassy-mqtt-connector-demo/.gitignore +++ b/examples/embassy-mqtt-connector-demo/.gitignore @@ -3,3 +3,5 @@ Cargo.lock *.bin *.elf *.hex +# Broker root CA for --features tls builds (user-supplied, see main.rs) +ca.der diff --git a/examples/embassy-mqtt-connector-demo/Cargo.toml b/examples/embassy-mqtt-connector-demo/Cargo.toml index 88359aa..6775c25 100644 --- a/examples/embassy-mqtt-connector-demo/Cargo.toml +++ b/examples/embassy-mqtt-connector-demo/Cargo.toml @@ -9,6 +9,9 @@ description = "AimDB example demonstrating MQTT connector with Embassy runtime" [features] default = ["embassy-runtime"] embassy-runtime = [] +# TLS (`mqtts://`) + broker authentication. Needs the broker's root CA at +# `ca.der` (DER) next to this Cargo.toml β€” see the TLS section in main.rs. +tls = ["aimdb-mqtt-connector/embassy-tls"] # Declare but don't enable these features to silence cfg warnings tokio-runtime = [] std = [] diff --git a/examples/embassy-mqtt-connector-demo/README.md b/examples/embassy-mqtt-connector-demo/README.md index f5e7a7e..40f9500 100644 --- a/examples/embassy-mqtt-connector-demo/README.md +++ b/examples/embassy-mqtt-connector-demo/README.md @@ -78,6 +78,35 @@ The example code in `src/main.rs` is a template. To adapt it: cargo build --release ``` +## TLS (`mqtts://`) + +The `tls` feature switches the demo to a TLS broker (`aimdb-mqtt-connector`'s +`embassy-tls` feature underneath): `mqtts://` with hostname resolution via +DNS, optional MQTT username/password, and an automatic SNTP time sync that +gates the first handshake (certificate validity needs real time β€” the board +has no RTC battery). + +1. In `src/main.rs`, set `MQTT_BROKER_HOST` and, if the broker requires it, + `MQTT_CREDENTIALS`. Prefer a DNS name: an IPv4 literal verifies only when + the certificate pins that IP in its CN (the repo's `dev/mosquitto` bench + CA does; public CAs won't issue such certs). IPv6 literals are rejected + at build. +2. Drop the broker's root CA next to `Cargo.toml`, DER-encoded β€” for the + `dev/mosquitto` bench broker: + ```bash + openssl x509 -in ../../dev/mosquitto/config/certs/ca.crt -outform der -out ca.der + ``` +3. Build (and flash) from this directory, so its `.cargo/config.toml` + selects the thumbv8m target and probe-rs runner: + ```bash + cargo run --release --features tls + ``` + Note: broker/CA config is compile-time β€” env vars like `SSL_CERT_FILE` + or `MQTT_BROKER_URL` only apply to the host-side Tokio demo. + +Cost: ~21 KB extra statically-allocated RAM (TLS record buffers) plus +~150–250 KB flash (pure-Rust crypto: p256 + rsa + p384 + SHA-2). + ## Testing the MQTT Client Without Hardware You can test the MQTT connector implementation using the Tokio runtime version: diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 3d83fe3..c6b0509 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -49,6 +49,27 @@ //! ```bash //! cargo run --example embassy-mqtt-connector-demo --features embassy-runtime,tracing //! ``` +//! +//! ## TLS (`mqtts://`) +//! +//! Build with `--features tls` to connect to a TLS broker instead: the URL +//! becomes `mqtts://` (hostname, resolved via DNS), the CONNECT authenticates +//! with `MQTT_CREDENTIALS`, and certificate time comes from SNTP +//! automatically. Before building: +//! +//! 1. Set `MQTT_BROKER_HOST` (prefer a DNS name; an IPv4 literal needs the +//! certificate to pin that IP in its CN β€” the `dev/mosquitto` bench CA +//! does) and `MQTT_CREDENTIALS` below. +//! 2. Drop the broker's root CA in DER form at the crate root; for the dev +//! bench: +//! ```bash +//! openssl x509 -in ../../dev/mosquitto/config/certs/ca.crt -outform der -out ca.der +//! ``` +//! 3. Build and flash from this directory (its `.cargo/config.toml` selects +//! the thumbv8m target and the probe-rs runner): +//! ```bash +//! cargo run --release --features tls +//! ``` extern crate alloc; @@ -70,6 +91,8 @@ use static_cell::StaticCell; use {defmt_rtt as _, panic_probe as _}; use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; +#[cfg(feature = "tls")] +use aimdb_mqtt_connector::embassy_client::TlsOptions; // Import shared types, monitors, and compile-time safe keys from the common crate use mqtt_connector_demo_common::{ @@ -168,11 +191,31 @@ async fn server_room_temp_producer(ctx: RuntimeContext, temperature: Producer = None; // Some(("user", "password")) + +/// The broker's root CA, DER-encoded (see the TLS section in the module doc). +#[cfg(feature = "tls")] +static MQTT_CA_DER: &[u8] = include_bytes!("../ca.der"); + #[embassy_executor::main] async fn main(spawner: Spawner) { // Initialize heap for the allocator @@ -228,6 +271,15 @@ async fn main(spawner: Spawner) { rng.fill_bytes(&mut seed); let seed = u64::from_le_bytes(seed); + // The one TRNG instance seeds the net stack (above) and then feeds the + // TLS handshake, so it parks in a static for the connector's `'static` + // bound. + #[cfg(feature = "tls")] + let rng = { + static TLS_RNG: StaticCell> = StaticCell::new(); + TLS_RNG.init(rng) + }; + info!("πŸ”§ Initializing Ethernet..."); // MAC address for this device @@ -262,8 +314,11 @@ async fn main(spawner: Spawner) { // gateway: Some(Ipv4Address::new(192, 168, 1, 1)), // }); - // Initialize network stack + // Initialize network stack (TLS builds carry two extra sockets: DNS + SNTP) + #[cfg(not(feature = "tls"))] static RESOURCES: StaticCell> = StaticCell::new(); + #[cfg(feature = "tls")] + static RESOURCES: StaticCell> = StaticCell::new(); static STACK_CELL: StaticCell> = StaticCell::new(); let (stack_obj, runner) = @@ -297,9 +352,12 @@ async fn main(spawner: Spawner) { // Create AimDB database with Embassy adapter let runtime = alloc::sync::Arc::new(EmbassyAdapter::new()); - // Build MQTT broker URL + // Build MQTT broker URL (the scheme selects the transport) use alloc::format; + #[cfg(not(feature = "tls"))] let broker_url = format!("mqtt://{}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); + #[cfg(feature = "tls")] + let broker_url = format!("mqtts://{}:{}", MQTT_BROKER_HOST, MQTT_BROKER_TLS_PORT); // ── AimX-over-serial: serve this db over USART3 (ST-LINK VCP, PD8=TX/PD9=RX) ── // A *second* connector alongside MQTT. With no extra cabling on a Nucleo-H563ZI @@ -327,11 +385,30 @@ async fn main(spawner: Spawner) { // Read-only: each record has a single writer (a sensor source, or MQTT for the // command records), so remote `record.set` is refused β€” peers can // list/drain/subscribe, not write. + let mqtt = MqttConnectorBuilder::new(&broker_url, stack).with_client_id("embassy-demo-001"); + + // TLS materials: the board's TRNG, the broker's root CA, and the record + // buffers (16 640 bytes read is the enforced minimum β€” a TLS 1.3 peer + // may send full-size records). `init_with` keeps the arrays off the stack. + #[cfg(feature = "tls")] + let mqtt = { + static TLS_READ_BUF: StaticCell<[u8; 16_640]> = StaticCell::new(); + static TLS_WRITE_BUF: StaticCell<[u8; 4_096]> = StaticCell::new(); + let mqtt = mqtt.with_tls(TlsOptions::new( + rng, + MQTT_CA_DER, + TLS_READ_BUF.init_with(|| [0; 16_640]), + TLS_WRITE_BUF.init_with(|| [0; 4_096]), + )); + match MQTT_CREDENTIALS { + Some((username, password)) => mqtt.with_credentials(username, password), + None => mqtt, + } + }; + let mut builder = AimDbBuilder::new() .runtime(runtime.clone()) - .with_connector( - MqttConnectorBuilder::new(&broker_url, stack).with_client_id("embassy-demo-001"), - ) + .with_connector(mqtt) .with_connector( SerialServer::new(serial_rx, serial_tx).security_policy(SecurityPolicy::read_only()), ); @@ -397,21 +474,26 @@ async fn main(spawner: Spawner) { info!("βœ… Database configured with multi-sensor MQTT:"); info!(" OUTBOUND: sensors/temp/indoor, outdoor, server_room"); info!(" INBOUND: commands/temp/indoor, outdoor"); - info!(" Broker: {}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); + info!(" Broker: {}", broker_url.as_str()); info!(" SERIAL (read-only AimX over USART3 / ST-LINK VCP):"); info!( " aimdb --features transport-serial --connect serial:///dev/ttyACM0?baud=115200 record list" ); info!(""); - info!( - "Subscribe: mosquitto_sub -h {} -t 'sensors/#' -v", - MQTT_BROKER_IP - ); - info!( - "Command: mosquitto_pub -h {} -t 'commands/temp/indoor' \\", - MQTT_BROKER_IP - ); - info!(" -m '{{\"action\":\"read\",\"sensor_id\":\"test\"}}'"); + #[cfg(not(feature = "tls"))] + { + info!( + "Subscribe: mosquitto_sub -h {} -t 'sensors/#' -v", + MQTT_BROKER_IP + ); + info!( + "Command: mosquitto_pub -h {} -t 'commands/temp/indoor' \\", + MQTT_BROKER_IP + ); + info!(" -m '{{\"action\":\"read\",\"sensor_id\":\"test\"}}'"); + } + #[cfg(feature = "tls")] + info!("TLS: first connect waits for the automatic SNTP time sync"); info!(""); static DB_CELL: StaticCell = StaticCell::new(); From 72c5ff398db563caaa6fe085d48653c19786c142 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 10 Jul 2026 23:19:00 +0000 Subject: [PATCH 07/11] feat: update heap size for MQTT demo and adjust binary path in flash script --- Cargo.toml | 7 +++++++ docs/design/044-embassy-mqtt-tls.md | 13 ++++++++----- examples/embassy-mqtt-connector-demo/flash.sh | 2 +- examples/embassy-mqtt-connector-demo/src/main.rs | 2 +- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e203272..e0c71f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,13 @@ resolver = "2" # Note: build aimdb-core only by default. Use make all to build the full workspace! default-members = ["aimdb-core"] +# debug = 2 embeds full DWARF location info; without it, probe-rs/defmt can't +# resolve file:line for release-profile embedded binaries ("insufficient +# DWARF info" at runtime). No effect on codegen or binary size β€” only debug +# sections grow. +[profile.release] +debug = 2 + [workspace.package] version = "1.1.0" edition = "2021" diff --git a/docs/design/044-embassy-mqtt-tls.md b/docs/design/044-embassy-mqtt-tls.md index cacadde..a864738 100644 --- a/docs/design/044-embassy-mqtt-tls.md +++ b/docs/design/044-embassy-mqtt-tls.md @@ -177,11 +177,14 @@ EMQX credential (issue 007) land. ## 8. Cost -On the reference STM32H563ZI (640 KB RAM / 2 MB flash), the TLS build adds -~21 KB of statically-allocated RAM (16 640 + 4 096 record buffers, D10) plus -the handshake's stack transient, and roughly 150–250 KB of flash -(p256 + rsa + p384 + SHA-2 + embedded-tls). Comfortable here; a smaller -part could drop `rsa`/`p384` (features are additive pass-throughs) and pin +On the reference STM32H563ZI (640 KB RAM / 2 MB flash), measured on +`embassy-mqtt-connector-demo` (`arm-none-eabi-size`, release profile): the +TLS build adds 21 568 bytes of statically-allocated RAM (16 640 + 4 096 +record buffers, D10, matching the estimate exactly) plus the handshake's +stack transient, and ~351 KB of flash β€” p256 + rsa + p384 + SHA-2 + +embedded-tls, plus the embedded CA and the TLS-path additions to the +mountain-mqtt fork port. Comfortable here; a smaller part could drop +`rsa`/`p384` (features are additive pass-throughs) and pin its broker CA chain to P-256. ## 9. Open questions diff --git a/examples/embassy-mqtt-connector-demo/flash.sh b/examples/embassy-mqtt-connector-demo/flash.sh index e30b49e..12bdd88 100755 --- a/examples/embassy-mqtt-connector-demo/flash.sh +++ b/examples/embassy-mqtt-connector-demo/flash.sh @@ -6,7 +6,7 @@ set -e -BINARY="../../target/thumbv8m.main-none-eabihf/debug/embassy-mqtt-connector-demo" +BINARY="../../target/thumbv8m.main-none-eabihf/release/embassy-mqtt-connector-demo" if [ ! -f "$BINARY" ]; then echo "Error: Binary not found at $BINARY" diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index c6b0509..18de5fe 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -221,7 +221,7 @@ async fn main(spawner: Spawner) { // Initialize heap for the allocator { use core::mem::MaybeUninit; - const HEAP_SIZE: usize = 49152; // 48KB heap (MQTT + serial AimX server JSON) + const HEAP_SIZE: usize = 98304; // 96KB heap (MQTT + serial AimX server JSON) static mut HEAP: [MaybeUninit; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE]; unsafe { let heap_ptr = core::ptr::addr_of_mut!(HEAP); From ed5c0f51a974f322f6122797a0b3c94832cb6c24 Mon Sep 17 00:00:00 2001 From: test Date: Sat, 11 Jul 2026 07:01:22 +0000 Subject: [PATCH 08/11] docs(changelog): record Embassy MQTT TLS support (design 044, WP7) Adds Unreleased entries in the root and aimdb-mqtt-connector changelogs for the embassy-tls feature, with_tls/with_credentials, and the SNTP time source landed in the preceding commits. --- CHANGELOG.md | 1 + aimdb-mqtt-connector/CHANGELOG.md | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f07d29..59b2bf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ multi-step migration round-trip suite and a trybuild compile-fail harness. ### Added +- **Embassy MQTT client TLS β€” `mqtts://` for embedded, WP7 ([design 044](docs/design/044-embassy-mqtt-tls.md)).** The Embassy MQTT connector can now dial a broker over TLS 1.3 (`embedded-tls`, pure-Rust `rustpki` certificate verification with `rsa`/`p384` so public CA chains verify out of the box), with SNI/hostname verification, DNS resolution, and a connector-internal SNTP time source that gates the first handshake so certificate validity is always checked against real time. Behind the new `embassy-tls` feature; `MqttConnectorBuilder::new` picks the transport from the URL scheme (`mqtt://` plain, `mqtts://` TLS) and gains `with_tls(TlsOptions)` (entropy, record buffers, SNTP server) and `with_credentials(username, password)` (MQTT CONNECT auth, both transports). The plain `mqtt://` path is untouched. The `embassy-mqtt-connector-demo` example gains a `tls` feature demonstrating the full flow against the repo's `dev/mosquitto` bench broker. ([aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md)) - **Design 034 Phase 3 β€” sans-io KNX/IP tunneling engine shared by both transports (Issue #135, [review doc Β§3.7](docs/design/034-technical-debt-review.md)).** The entire tunneling lifecycle β€” CONNECT_REQUEST/RESPONSE handshake, TUNNELING_REQUEST/ACK sequence + pending-ACK bookkeeping, keepalive (CONNECTIONSTATE_REQUEST) scheduling, ACK-timeout sweeps, and reconnect-with-backoff β€” now lives **once**, in the new runtime-neutral `aimdb_knx_connector::tunnel` module (`no_std + alloc`, no tokio/embassy imports), driven as a poll-based state machine (events in, `Action`s out, `next_deadline()` for timer arming). `tokio_client.rs` (988 β†’ ~530 lines incl. a new fake-gateway integration test) and `embassy_client.rs` (1,055 β†’ ~450 lines) are reduced to socket shims; the previously untestable handshake/ACK/keepalive/reconnect paths now have 15 host-run unit tests plus a scripted localhost-UDP roundtrip test. Behavioral unifications: Embassy gains the 5 s CONNECT_RESPONSE timeout (previously waited forever), both shims reconnect on fatal socket errors, and the dead tokio-only per-publish ACK oneshot is dropped (it was always `None`); the CONNECT_REQUEST HPAI stays per-transport (`LocalEndpoint`: tokio = real bound address, Embassy = NAT mode). ([aimdb-knx-connector](aimdb-knx-connector/CHANGELOG.md)) - **Design 034 Phase 2 β€” dyn-safe `RuntimeOps` capability trait (Issue #130, [review doc](docs/design/034-technical-debt-review.md)).** New object-safe trait in `aimdb-executor` (`name` / `now_nanos` / `unix_time` / boxed `sleep` / `log(LogLevel, …)`) so a runtime adapter can travel as `Arc` instead of a generic parameter β€” the groundwork for removing `R` from the record object graph (#131). Implemented by `TokioAdapter`, `EmbassyAdapter`, and `WasmAdapter`, each covered by a shared behavioral contract test. `BoxFuture`'s canonical definition moves to `aimdb-executor` (re-exported unchanged from `aimdb-core`). ([aimdb-executor](aimdb-executor/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-wasm-adapter](aimdb-wasm-adapter/CHANGELOG.md)) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 9b83731..a4b629f 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Embassy client: TLS (`mqtts://`) and broker authentication ([design 044](../docs/design/044-embassy-mqtt-tls.md), WP7).** New `embassy-tls` feature (`embassy-runtime` + `embedded-tls`/`embedded-io-async`/`rand_core`, `embassy-net/dns`, `embassy-net/udp`) adds an `embedded-tls` 1.3 session over the Embassy TCP socket, with pure-Rust (`rustpki`) certificate verification (`rsa` + `p384`, so public CA chains verify out of the box) and SNI/hostname verification taken from the broker URL. `MqttConnectorBuilder::new` now accepts `mqtts://host[:port]` (default port 8883) alongside plain `mqtt://` (1883); the scheme selects the transport at `build()`. New `MqttConnectorBuilder::with_tls(TlsOptions)` supplies the TLS materials β€” entropy (`&'static mut dyn CryptoRngCore`, app-owned TRNG), app-provided static record buffers, and the SNTP server address; `build()` errors if `mqtts://` is used without `.with_tls(...)`, if `.with_tls(...)` is used with a plain `mqtt://` URL, or if the `embassy-tls` feature is off. IPv6 broker literals are rejected at `build()` (can never pass certificate verification); IPv4 literals are allowed with a `defmt` warning (only a private CA that pins the dotted quad in its CN will verify). A connector-internal SNTP (UDP) task backs the TLS clock and gates the first handshake on a successful time sync, so certificate validity is always checked. The plain `mqtt://` path is unchanged. +- **`MqttConnectorBuilder::with_credentials(username, password)` (Embassy, design 044 D8).** Feeds the MQTT CONNECT username/password on both the plain and TLS transports. The `aimdb-dev/mountain-mqtt` fork submodule is bumped to pick up upstream 0.4's `ConnectionSettings::with_auth`/`authenticated` (`aimdb-dev/mountain-mqtt@89a7129`). +- `make check` gains an `embassy-runtime,embassy-tls,defmt` clippy leg on `thumbv7em-none-eabihf`. + +### Changed + +- **Embassy broker URL parsing now validates the scheme.** `MqttConnectorBuilder::new`'s URL must be `mqtt://` or `mqtts://` (previously any scheme's host/port were used as-is); this is what selects the transport for the `embassy-tls` change above. + ### Changed (breaking) - **Issue #131:** the Embassy `MqttConnectorBuilder::new` takes the network stack β€” `MqttConnectorBuilder::new(broker_url, stack)` β€” since the deleted `EmbassyNetwork` runtime trait can no longer supply it; both `ConnectorBuilder` impls and the `MqttLinkExt`/`MqttOutboundLinkExt` link-builder ext traits are non-generic over the runtime. From 7f2b2131d31e657ce338bb41bb4e857d71f669fe Mon Sep 17 00:00:00 2001 From: test Date: Sat, 11 Jul 2026 19:47:29 +0000 Subject: [PATCH 09/11] refactor(mqtt-connector): reuse upstream session loop; fix plain-path resubscribe The Embassy connector no longer copies mountain-mqtt-embassy's session loop (`State`/`ChannelEventHandler`/`try_action`/`handle_messages`, ~195 lines). It now calls the newly-public upstream `handle_messages`, so the plain and TLS paths share one keep-alive/action-dispatch/event implementation and can't drift. The plain `mqtt://` path switches to `run_with_subscriptions`, which re-subscribes the inbound topics on every connection. Previously it queued subscribe actions once at startup, so subscriptions were silently lost after a reconnect. Bumps the mountain-mqtt submodule to the matching upstream change. Net: -215/+30 in the connector; behaviour on the TLS path is unchanged. Co-Authored-By: Claude Opus 4.8 --- _external/mountain-mqtt | 2 +- aimdb-mqtt-connector/CHANGELOG.md | 1 + aimdb-mqtt-connector/src/embassy_client.rs | 31 ++- aimdb-mqtt-connector/src/embassy_tls.rs | 245 +++------------------ 4 files changed, 47 insertions(+), 232 deletions(-) diff --git a/_external/mountain-mqtt b/_external/mountain-mqtt index 89a7129..f84c837 160000 --- a/_external/mountain-mqtt +++ b/_external/mountain-mqtt @@ -1 +1 @@ -Subproject commit 89a7129a39818ebacb0b2c676a71e2df585e71ce +Subproject commit f84c83732b05bc046afa316667f826e90b62dbed diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index a4b629f..744db59 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Embassy connector reuses the upstream session loop instead of copying it.** The TLS path no longer duplicates mountain-mqtt-embassy's `handle_messages`/`State`/`ChannelEventHandler`/`try_action` (~195 lines): the `aimdb-dev/mountain-mqtt` fork now exposes them publicly (plus a `run_with_subscriptions`), so the plain and TLS transports share one keep-alive/action-dispatch/event loop and can no longer drift. The plain `mqtt://` path also switches to `run_with_subscriptions`, which **re-subscribes inbound topics on every connection** β€” previously it queued subscribe actions once at startup, so subscriptions were silently lost after a reconnect. The submodule is bumped to the matching change; no public API change. - **Embassy broker URL parsing now validates the scheme.** `MqttConnectorBuilder::new`'s URL must be `mqtt://` or `mqtts://` (previously any scheme's host/port were used as-is); this is what selects the transport for the `embassy-tls` change above. ### Changed (breaking) diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index e58e83d..74d35ab 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -537,11 +537,12 @@ fn init_channels() -> (ActionSender, ActionReceiver, EventSender, EventReceiver) ) } -/// Set up the plain-TCP broker manager (mountain-mqtt-embassy's `run`), -/// returning the action sender (outbound), the event receiver (inbound), and -/// the manager task future (which first queues the topic subscriptions, then -/// runs the broker loop). Synchronous β€” no `.await` β€” so the caller's `build` -/// future stays `Send`. +/// Set up the plain-TCP broker manager +/// (mountain-mqtt-embassy's `run_with_subscriptions`), returning the action +/// sender (outbound), the event receiver (inbound), and the manager task +/// future. The manager re-subscribes the inbound topics on every connection, +/// so routing survives reconnects. Synchronous β€” no `.await` β€” so the caller's +/// `build` future stays `Send`. fn setup_manager( broker: &BrokerUrl, connection_settings: ConnectionSettings<'static>, @@ -559,24 +560,21 @@ fn setup_manager( let settings = Settings::new(broker_addr, broker.port); let network = stack.get(); - // Manager task: queue subscriptions, then run the broker loop (never returns). - let sub_sender = action_sender; + // Manager task: run the broker loop (never returns). The manager + // re-subscribes these topics on every connection, so inbound routing + // survives reconnects (unlike queuing subscribe actions once at startup). let manager_task = into_box_future(async move { - for topic in &topics { - sub_sender - .send(AimdbMqttAction::Subscribe { - topic: topic.clone(), - qos: QualityOfService::Qos1, - }) - .await; - } + let subscribe_topics: Vec<(&str, QualityOfService)> = topics + .iter() + .map(|topic| (topic.as_str(), QualityOfService::Qos1)) + .collect(); #[cfg(feature = "defmt")] defmt::info!("MQTT background task starting"); #[allow(unreachable_code)] { - let _: () = mqtt_manager::run::< + let _: () = mqtt_manager::run_with_subscriptions::< AimdbMqttAction, AimdbMqttEvent, MAX_PROPERTIES, @@ -586,6 +584,7 @@ fn setup_manager( *network, connection_settings, settings, + &subscribe_topics, event_sender, action_receiver, ) diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embassy_tls.rs index f3318c1..67edb9f 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embassy_tls.rs @@ -7,11 +7,14 @@ //! from the [`sntp`](crate::sntp) task; entropy comes from the //! application-injected TRNG ([`TlsOptions::new`]). //! -//! The session loop ([`handle_messages`], [`State`], [`ChannelEventHandler`]) -//! is a port of mountain-mqtt-embassy's private equivalents (MIT OR -//! Apache-2.0): upstream `run()` constructs a bare `TcpSocket` internally and -//! offers no seam for a TLS session. Keep the loop in sync with the fork when -//! bumping it. +//! The session loop is mountain-mqtt-embassy's own public +//! [`handle_messages`](mountain_mqtt_embassy::mqtt_manager::handle_messages) +//! (with [`State`](mountain_mqtt_embassy::mqtt_manager::State) / +//! [`ChannelEventHandler`](mountain_mqtt_embassy::mqtt_manager::ChannelEventHandler)): +//! it is transport-agnostic (generic over `Client`), so the only thing this +//! module supplies is the transport β€” resolve β†’ TCP β†’ TLS handshake β†’ session. +//! Upstream `run()` shares that exact loop, keeping the plain and TLS paths in +//! lock-step with no copied code to drift. use alloc::string::String; use alloc::vec::Vec; @@ -23,7 +26,7 @@ use embassy_net::tcp::TcpSocket; use embassy_net::{IpAddress, Stack}; use embassy_sync::blocking_mutex::raw::NoopRawMutex; use embassy_sync::channel::{Receiver, Sender}; -use embassy_time::{Delay, Instant, Timer}; +use embassy_time::{Delay, Timer}; use embedded_tls::pki::CertVerifier; use embedded_tls::{ @@ -33,16 +36,15 @@ use embedded_tls::{ use embedded_io_async::Write as _; -use mountain_mqtt::client::{ - Client, ClientError, ClientNoQueue, ClientReceivedEvent, ConnectionSettings, EventHandler, - EventHandlerError, -}; +use mountain_mqtt::client::{ClientNoQueue, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::embedded_hal_async::DelayEmbedded; use mountain_mqtt::error::{PacketReadError, PacketWriteError}; -use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; +use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packet_client::Connection; -use mountain_mqtt_embassy::mqtt_manager::{Error, FromApplicationMessage, MqttEvent, Settings}; +use mountain_mqtt_embassy::mqtt_manager::{ + handle_messages, ChannelEventHandler, MqttEvent, Settings, State, +}; use crate::embassy_client::{ AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES, @@ -258,6 +260,13 @@ pub(crate) async fn run_tls( let mut tx_buffer = [0u8; BUFFER_SIZE]; let mut mqtt_buffer = [0u8; BUFFER_SIZE]; + // Re-subscribed by `handle_messages` on every (re)connection, so inbound + // routing survives reconnects. Built once β€” borrows `topics` for the loop. + let subscribe_topics: Vec<(&str, QualityOfService)> = topics + .iter() + .map(|topic| (topic.as_str(), QualityOfService::Qos1)) + .collect(); + let mut connection_index = 0u32; loop { @@ -334,16 +343,18 @@ pub(crate) async fn run_tls( let delay = DelayEmbedded::new(Delay); let timeout_millis = settings.response_timeout.as_millis() as u32; - let state = RefCell::new(State::new()); + let state: RefCell> = RefCell::new(State::new()); let connection_id = ConnectionId::new(connection_index); connection_index += 1; - let event_handler = ChannelEventHandler { - connection_id, - event_sender: &event_sender, - state: &state, - }; + let event_handler: ChannelEventHandler< + '_, + AimdbMqttAction, + AimdbMqttEvent, + MAX_PROPERTIES, + CHANNEL_SIZE, + > = ChannelEventHandler::new(connection_id, &event_sender, &state); let mut client = ClientNoQueue::new( connection, @@ -358,7 +369,7 @@ pub(crate) async fn run_tls( &mut client, &state, &connection_settings, - &topics, + &subscribe_topics, &event_sender, &mut action_receiver, &settings, @@ -402,199 +413,3 @@ pub(crate) fn host_ip_literal(host: &str) -> Option { .unwrap_or(host); host.parse::().ok() } - -// --- Ported session loop (see module doc) --------------------------------- - -struct State { - /// The instant of the most recent proof the connection is live (set at - /// connect, refreshed on every Ack from the server). - last_connection_event: Instant, - /// A failed action to retry on the next loop turn / connection. - pending_action: Option, -} - -impl State { - fn new() -> Self { - Self { - last_connection_event: Instant::now(), - pending_action: None, - } - } - fn record_connection_event(&mut self) { - self.last_connection_event = Instant::now(); - } -} - -struct ChannelEventHandler<'a> { - connection_id: ConnectionId, - event_sender: &'a Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>, - state: &'a RefCell, -} - -impl EventHandler for ChannelEventHandler<'_> { - async fn handle_event( - &mut self, - event: ClientReceivedEvent<'_, MAX_PROPERTIES>, - ) -> Result<(), EventHandlerError> { - match event { - ClientReceivedEvent::ApplicationMessage(message) => { - let event = AimdbMqttEvent::from_application_message(&message)?; - self.event_sender - .send(MqttEvent::ApplicationEvent { - connection_id: self.connection_id, - event, - }) - .await; - } - ClientReceivedEvent::Ack => { - self.state.borrow_mut().record_connection_event(); - } - ClientReceivedEvent::SubscriptionGrantedBelowMaximumQos { - granted_qos, - maximum_qos, - } => { - self.event_sender - .send(MqttEvent::SubscriptionGrantedBelowMaximumQos { - connection_id: self.connection_id, - granted_qos, - maximum_qos, - }) - .await - } - ClientReceivedEvent::PublishedMessageHadNoMatchingSubscribers => { - self.event_sender - .send(MqttEvent::PublishedMessageHadNoMatchingSubscribers { - connection_id: self.connection_id, - }) - .await - } - ClientReceivedEvent::NoSubscriptionExisted => { - self.event_sender - .send(MqttEvent::NoSubscriptionExisted { - connection_id: self.connection_id, - }) - .await - } - } - Ok(()) - } -} - -async fn try_action<'a, C>( - current_connection_id: ConnectionId, - client: &mut C, - state: &RefCell, - connection_settings: &ConnectionSettings<'static>, - mut action: AimdbMqttAction, - is_retry: bool, -) -> Result<(), ClientError> -where - C: Client<'a>, -{ - if let Err(e) = action - .perform( - client, - connection_settings.client_id(), - current_connection_id, - is_retry, - ) - .await - { - state.borrow_mut().pending_action = Some(action); - return Err(e); - } - Ok(()) -} - -/// Connect, re-subscribe the inbound routes, then handle messages until an -/// error ends the session. Unlike the plain path (which queues subscriptions -/// once at startup), subscribing per session means inbound routing survives -/// reconnects. -#[allow(clippy::too_many_arguments)] -async fn handle_messages<'a, C>( - current_connection_id: ConnectionId, - client: &mut C, - state: &RefCell, - connection_settings: &ConnectionSettings<'static>, - topics: &[String], - event_sender: &Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>, - action_receiver: &mut Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>, - settings: &Settings, -) -> Result<(), Error> -where - C: Client<'a>, -{ - client.connect(connection_settings).await?; - - event_sender - .send(MqttEvent::Connected { - connection_id: current_connection_id, - }) - .await; - - for topic in topics { - client.subscribe(topic, QualityOfService::Qos1).await?; - } - - let mut connection_instant = Some(Instant::now()); - let mut last_ping_instant = Instant::now(); - - loop { - Timer::after(settings.poll_interval).await; - - if last_ping_instant.elapsed() > settings.ping_interval { - last_ping_instant = Instant::now(); - client.send_ping().await?; - } - - // Check for stabilisation - if let Some(instant) = connection_instant { - if instant.elapsed() > settings.stabilisation_interval { - connection_instant = None; - event_sender - .send(MqttEvent::ConnectionStable { - connection_id: current_connection_id, - }) - .await; - } - } - - // Check for too long since last connection event - let elapsed = state.borrow().last_connection_event.elapsed(); - if elapsed > settings.connection_event_max_interval { - #[cfg(feature = "defmt")] - defmt::warn!("MQTT-TLS: server unresponsive"); - return Err(Error::MqttServerUnresponsive); - } - - // Poll with no delay while we have mqtt packets - while client.poll(false).await? {} - - // If we have a pending action, try to perform it - let pending_action = state.borrow_mut().pending_action.take(); - if let Some(action) = pending_action { - try_action( - current_connection_id, - client, - state, - connection_settings, - action, - true, - ) - .await?; - } - - // Handle actions from receiver - while let Ok(action) = action_receiver.try_receive() { - try_action( - current_connection_id, - client, - state, - connection_settings, - action, - false, - ) - .await?; - } - } -} From 279b5af9f4d9a37fa6e04245836f6ed2ad13bf2c Mon Sep 17 00:00:00 2001 From: test Date: Sat, 11 Jul 2026 20:33:43 +0000 Subject: [PATCH 10/11] chore(embassy): re-pin submodule to aimdb-dev/embassy main (217dbc9e1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the `_external/embassy` pin from `20f6d85e8` (the standalone #6390 poll-methods commit) to the fork's main `217dbc9e1`, which merges that commit onto current upstream. No code change β€” 217dbc9e1's tree contains the same poll-method additions; this just points the pin at the fork's main line so the branch tracks main instead of a feature commit. Co-Authored-By: Claude Opus 4.8 --- _external/embassy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_external/embassy b/_external/embassy index 20f6d85..217dbc9 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit 20f6d85e827d3ecf50419001f2746fe5ec2186cc +Subproject commit 217dbc9e1d8af38b43b104e9739c16e97bbdf8ec From fb8f1248da5cee349741cd15641ae209cc347a80 Mon Sep 17 00:00:00 2001 From: test Date: Sat, 11 Jul 2026 20:46:21 +0000 Subject: [PATCH 11/11] chore(embassy): refresh fork main to upstream + keep #6390 carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-pins `_external/embassy` to d60764b5 β€” the aimdb-dev/embassy fork main merged up to current embassy-rs upstream (124 commits) with the #6390 public poll_next_message / poll_changed carry retained. Verified compiling on thumbv7em-none-eabihf: embassy-adapter, mqtt-connector (embassy-tls), demo binary. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 17 ++++++++++++++--- _external/embassy | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48c4c4e..f32836f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1357,8 +1357,6 @@ version = "0.1.2" name = "embassy-hal-internal" version = "0.5.0" dependencies = [ - "aligned", - "as-slice", "cortex-m", "critical-section", "defmt 1.0.1", @@ -1522,6 +1520,7 @@ dependencies = [ "rand_core 0.6.4", "rand_core 0.9.3", "regex", + "sdio", "sdio-host", "static_assertions", "stm32-fmc 0.4.0", @@ -3570,6 +3569,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdio" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "203db256bf75fbd6eb96e82b0c109398442183de06a67a7182951a463a17f9fe" +dependencies = [ + "aligned", + "block-device-driver", + "embedded-hal 1.0.0", + "embedded-hal-async", +] + [[package]] name = "sdio-host" version = "0.9.0" @@ -3912,7 +3923,7 @@ dependencies = [ [[package]] name = "stm32-metapac" version = "21.0.0" -source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-98c747c5a5eb2fe1bfa452d6375445a1c3c51628#ff0350aebb88f1498cbf5800305de327df02012c" +source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-43d482dc249c6755fe990b67e86be4fdc9092909#1d8486efb2e9226b08ddfcd424841a11fb0620a0" dependencies = [ "cortex-m", "cortex-m-rt", diff --git a/_external/embassy b/_external/embassy index 217dbc9..d60764b 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit 217dbc9e1d8af38b43b104e9739c16e97bbdf8ec +Subproject commit d60764b5d0aa8e89b8a6bae9cec2dfa601dfbedb