From f8a4d53851c4ec052c30795de7a9e5a49b35c069 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 5 Jul 2026 09:22:31 +0200 Subject: [PATCH] feat(firmware): add Wi-Fi + TLS spike (Spike 6) Standalone `wifi_tls` binary: station assoc, SNTP, then a validated HTTPS GET to api.github.com against the esp-idf cert bundle. Gates Spike 7 (gitoxide push). Creds come from firmware/.env via build.rs. --- firmware/.env.example | 17 +++ firmware/Cargo.toml | 15 +++ firmware/build.rs | 12 +++ firmware/justfile | 21 +++- firmware/sdkconfig.defaults | 17 ++- firmware/src/bin/wifi_tls.rs | 202 +++++++++++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 firmware/.env.example create mode 100644 firmware/src/bin/wifi_tls.rs diff --git a/firmware/.env.example b/firmware/.env.example new file mode 100644 index 0000000..442db89 --- /dev/null +++ b/firmware/.env.example @@ -0,0 +1,17 @@ +# Build-time config for the network spikes (6/7). Copy to `.env` and fill in. +# `.env` is gitignored; `just` loads it automatically (dotenv-load) so the +# values reach build.rs, which emits them as compile-time env for the binary. +# +# cp .env.example .env # then edit +# just flash-wifi # Spike 6 — Wi-Fi + TLS +# +# Only the editor build (`just flash`) needs none of these. + +# Home Wi-Fi (2.4 GHz — the ESP32-S3 has no 5 GHz radio). +TW_WIFI_SSID=your-ssid +TW_WIFI_PASS=your-password +# Note: the spike assumes WPA2-Personal. Open network → leave TW_WIFI_PASS empty. +# A WPA3-only AP needs AuthMethod::WPA3Personal in src/bin/wifi_tls.rs. + +# Spike 7 (gitoxide push) will add: TW_REMOTE_URL, TW_GH_USER, TW_PAT, +# TW_AUTHOR_NAME, TW_AUTHOR_EMAIL. diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index a34befa..21c5013 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -5,11 +5,23 @@ authors = ["Julien Calixte "] edition = "2024" resolver = "2" rust-version = "1.85" +# Declare every binary explicitly so a spike program under src/bin/ doesn't get +# an auto-discovered target with the default test harness (which trips up +# rust-analyzer — see README "harness = false" note). +autobins = false [[bin]] name = "firmware" +path = "src/main.rs" harness = false # do not use the built-in cargo test harness -> resolve rust-analyzer errors +# Spike 6 — Wi-Fi + TLS. Standalone bench program, kept separate from the +# editor firmware. Flash with `just flash-wifi`. +[[bin]] +name = "wifi_tls" +path = "src/bin/wifi_tls.rs" +harness = false + [profile.release] opt-level = "s" @@ -28,6 +40,9 @@ esp-idf-svc = { version = "0.52.1", features = ["critical-section", "embassy-tim # Remove `generic-queue-8` if you plan to use `embassy-time` WITH `embassy-executor` embassy-time = { version = "0.5", features = ["generic-queue-8"] } embedded-graphics = "0.8" +# Traits shared with esp-idf-svc (Spike 6 uses `embedded_svc::http::Method`). +# Already in the tree via esp-idf-svc; pinned here so the spike names it directly. +embedded-svc = "0.29" [build-dependencies] embuild = "0.33" diff --git a/firmware/build.rs b/firmware/build.rs index 8eaeefd..981cf65 100644 --- a/firmware/build.rs +++ b/firmware/build.rs @@ -21,6 +21,18 @@ fn main() { .unwrap_or_else(|| "unknown".into()); println!("cargo:rustc-env=BUILD_GIT={git}"); println!("cargo:rustc-env=BUILD_TIME={time}Z"); + + // Wi-Fi credentials for the network spikes (6/7) and, later, the runtime. + // Read at build time and emitted as compile-time env so a binary can pull + // them in with env!(). Empty when unset: the network spike checks at + // runtime and prints a clear message, so the *editor* build never has to + // carry Wi-Fi creds. Source them from firmware/.env (loaded by `just`). + for var in ["TW_WIFI_SSID", "TW_WIFI_PASS"] { + let val = std::env::var(var).unwrap_or_default(); + println!("cargo:rustc-env={var}={val}"); + println!("cargo:rerun-if-env-changed={var}"); + } + // Pointing rerun-if-changed at a file that never exists forces this // script to rerun on every build, keeping BUILD_TIME fresh. println!("cargo:rerun-if-changed=.force-build-stamp"); diff --git a/firmware/justfile b/firmware/justfile index b0c5816..64f46aa 100644 --- a/firmware/justfile +++ b/firmware/justfile @@ -2,8 +2,13 @@ # Recipes source ~/export-esp.sh themselves (LIBCLANG_PATH + Xtensa GCC), # so no per-shell setup is needed before calling just. +# Load firmware/.env (Wi-Fi creds for the network spikes) into recipe env. +# Absent .env is fine — the editor build ignores TW_* entirely. +set dotenv-load := true + esp_env := ". ~/export-esp.sh &&" elf := "target/xtensa-esp32s3-espidf/release/firmware" +elf_wifi := "target/xtensa-esp32s3-espidf/release/wifi_tls" # list recipes default: @@ -11,16 +16,28 @@ default: # compile (release) build: - {{esp_env}} cargo build --release + {{esp_env}} cargo build --release --bin firmware # build + flash + open serial monitor flash: - {{esp_env}} cargo run --release + {{esp_env}} cargo run --release --bin firmware # serial monitor only, with decoded backtraces monitor: espflash monitor --elf {{elf}} +# Spike 6 — build the Wi-Fi + TLS spike (needs TW_WIFI_SSID / TW_WIFI_PASS in .env) +build-wifi: + {{esp_env}} cargo build --release --bin wifi_tls + +# Spike 6 — flash + monitor the Wi-Fi + TLS spike +flash-wifi: + {{esp_env}} cargo run --release --bin wifi_tls + +# serial monitor for the Wi-Fi spike, with decoded backtraces +monitor-wifi: + espflash monitor --elf {{elf_wifi}} + # detect board, print chip/MAC/flash size info: espflash board-info diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index ca7a8a8..cd43da6 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -1,12 +1,21 @@ # Rust often needs a bit of an extra main task stack size compared to C (the default is 3K) -# You might have to increase this further if you allocate large stack variables in the main task -CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +# You might have to increase this further if you allocate large stack variables in the main task. +# Bumped for the TLS handshake (Spike 6): mbedtls runs on the calling task and +# wants several KB of stack on top of the app's own usage. +CONFIG_ESP_MAIN_TASK_STACK_SIZE=12288 # Increase a bit these stack sizes as they are also a bit too small by default CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=4096 -# You might have to increase this further if you spawn your own Rust threads -# that allocate large stack variables; or better yet - use +# You might have to increase this further if you spawn your own Rust threads +# that allocate large stack variables; or better yet - use # `std::thread::Builder::new().stack_size(XXX)` for spawning CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=4096 + +# TLS trust store (Spike 6 — Wi-Fi + TLS, the gate for Spike 7 gitoxide push). +# The certificate bundle backs esp_crt_bundle_attach so an HTTPS GET to +# api.github.com validates against real roots. FULL rather than the common +# subset so a less common CA in the chain can't surprise us on the bench. +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y diff --git a/firmware/src/bin/wifi_tls.rs b/firmware/src/bin/wifi_tls.rs new file mode 100644 index 0000000..595426c --- /dev/null +++ b/firmware/src/bin/wifi_tls.rs @@ -0,0 +1,202 @@ +//! Spike 6 — Wi-Fi + TLS. +//! +//! A small standalone bench program (separate binary from the editor firmware) +//! that proves the networking + TLS stack end to end: +//! +//! 1. Bring up the station and associate with the home AP. +//! 2. Sync the clock over SNTP — mbedtls checks the server cert's +//! not-before/not-after against wall time, so without this the 1970 RTC +//! makes every handshake fail with "certificate is not valid yet". +//! 3. HTTPS GET https://api.github.com/ with cert-chain validation against +//! the esp-idf certificate bundle (esp_crt_bundle_attach), and read the +//! response body. +//! +//! A validated GET is the whole point: it's the gate for Spike 7 (gitoxide +//! push over HTTPS + PAT). Free heap is logged around the handshake because +//! TLS heap pressure on this chip is a top-3 watched risk (see qfd.md §6). +//! +//! Credentials come from build-time env (build.rs → env!): set TW_WIFI_SSID / +//! TW_WIFI_PASS in firmware/.env and run `just flash-wifi`. + +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Context, Result}; +use embedded_svc::http::Method; +use esp_idf_svc::eventloop::EspSystemEventLoop; +use esp_idf_svc::hal::delay::FreeRtos; +use esp_idf_svc::hal::peripherals::Peripherals; +use esp_idf_svc::http::client::{Configuration as HttpConfig, EspHttpConnection}; +use esp_idf_svc::nvs::EspDefaultNvsPartition; +use esp_idf_svc::sntp::{EspSntp, SyncStatus}; +use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi}; + +/// Injected by build.rs so serial output identifies the exact build. +const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT")); + +/// Wi-Fi credentials, baked in at build time from firmware/.env. Empty when +/// unset — checked at runtime so the editor build never depends on them. +const WIFI_SSID: &str = env!("TW_WIFI_SSID"); +const WIFI_PASS: &str = env!("TW_WIFI_PASS"); + +/// The validated endpoint. Root of the GitHub REST API: returns JSON, needs a +/// User-Agent, and is served over a normal public CA chain — a faithful +/// stand-in for the api.github.com host Spike 7 will push through. +const TEST_URL: &str = "https://api.github.com/"; + +/// SNTP first-sync budget. Home networks resolve pool.ntp.org and answer well +/// within this; failing past it is a real problem worth surfacing. +const SNTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); + +fn main() -> Result<()> { + // Required once before any esp-idf-svc call; some runtime patches only link + // if this symbol is referenced. See esp-idf-template#71. + esp_idf_svc::sys::link_patches(); + esp_idf_svc::log::EspLogger::initialize_default(); + + log::info!("Typoena — Spike 6 (Wi-Fi + TLS), {BUILD_TAG}"); + + match run() { + Ok(()) => log::info!("✅ Spike 6 complete — Wi-Fi assoc + SNTP + validated HTTPS GET"), + Err(e) => log::error!("❌ Spike 6 failed: {e:?}"), + } + + // Idle instead of returning, so the result stays on screen and Wi-Fi/other + // tasks keep running for inspection rather than the app winding down. + loop { + FreeRtos::delay_ms(1000); + } +} + +fn run() -> Result<()> { + if WIFI_SSID.is_empty() { + bail!("TW_WIFI_SSID is empty — set it in firmware/.env (see .env.example) and rebuild"); + } + + let peripherals = Peripherals::take()?; + let sys_loop = EspSystemEventLoop::take()?; + let nvs = EspDefaultNvsPartition::take()?; + + let mut wifi = BlockingWifi::wrap( + EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?, + sys_loop, + )?; + + connect_wifi(&mut wifi)?; + let ip = wifi.wifi().sta_netif().get_ip_info()?; + log::info!("Wi-Fi up — IP {}, GW {}", ip.ip, ip.subnet.gateway); + + sync_clock()?; + + https_get(TEST_URL)?; + Ok(()) +} + +/// Associate with the configured AP and wait for the netif (DHCP) to come up. +fn connect_wifi(wifi: &mut BlockingWifi>) -> Result<()> { + // Open network → no auth; otherwise WPA2-Personal (see .env.example for WPA3). + let auth_method = if WIFI_PASS.is_empty() { + AuthMethod::None + } else { + AuthMethod::WPA2Personal + }; + + wifi.set_configuration(&Configuration::Client(ClientConfiguration { + ssid: WIFI_SSID + .try_into() + .ok() + .context("SSID longer than 32 bytes")?, + password: WIFI_PASS + .try_into() + .ok() + .context("password longer than 64 bytes")?, + auth_method, + ..Default::default() + }))?; + + wifi.start()?; + log::info!("associating with \"{WIFI_SSID}\"…"); + wifi.connect().context("Wi-Fi association failed")?; + wifi.wait_netif_up().context("DHCP / netif never came up")?; + Ok(()) +} + +/// Kick off SNTP and block until the first sync (or time out). Required before +/// TLS: cert validity is checked against wall time. +fn sync_clock() -> Result<()> { + let sntp = EspSntp::new_default()?; + log::info!("SNTP started, waiting for first sync…"); + + let start = Instant::now(); + while sntp.get_sync_status() != SyncStatus::Completed { + if start.elapsed() >= SNTP_TIMEOUT { + bail!("SNTP did not sync within {SNTP_TIMEOUT:?} — TLS cert validity would fail"); + } + FreeRtos::delay_ms(100); + } + + let unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // A synced clock lands well past this (2023-11-14); anything below means the + // RTC never actually advanced and TLS would reject on validity. + if unix < 1_700_000_000 { + bail!("clock still at {unix} after SNTP sync — refusing TLS with a bad wall clock"); + } + log::info!("clock synced — unix {unix}"); + Ok(()) +} + +/// HTTPS GET with cert-chain validation against the esp-idf certificate bundle. +/// Logs status, the first chunk of the body, and free heap around the request. +fn https_get(url: &str) -> Result<()> { + let heap_before = unsafe { esp_idf_svc::sys::esp_get_free_heap_size() }; + + let mut conn = EspHttpConnection::new(&HttpConfig { + // Validate the server chain against the bundled roots. If this is None, + // the handshake skips verification — which would defeat the spike. + crt_bundle_attach: Some(esp_idf_svc::sys::esp_crt_bundle_attach), + ..Default::default() + }) + .context("creating the HTTPS connection (TLS init)")?; + + // GitHub rejects requests without a User-Agent. + let headers = [ + ("User-Agent", "typoena-spike6"), + ("Accept", "application/vnd.github+json"), + ]; + conn.initiate_request(Method::Get, url, &headers) + .context("TLS handshake / request send failed")?; + conn.initiate_response()?; + + let status = conn.status(); + log::info!("HTTPS GET {url} → {status}"); + + // Preview the first chunk, then drain the rest so we log the real byte count + // (proves the encrypted stream reads back cleanly, not just the handshake). + let mut buf = [0u8; 512]; + let first = conn.read(&mut buf)?; + log::info!( + "body[..{first}]: {}", + String::from_utf8_lossy(&buf[..first]).replace('\n', " ") + ); + let mut total = first; + loop { + let n = conn.read(&mut buf)?; + if n == 0 { + break; + } + total += n; + } + + let heap_after = unsafe { esp_idf_svc::sys::esp_get_free_heap_size() }; + log::info!( + "read {total} bytes; free heap {heap_before} → {heap_after} (Δ {} B during TLS)", + heap_before as i64 - heap_after as i64 + ); + + if !(200..300).contains(&status) { + bail!("unexpected HTTP status {status} (TLS validated, but the request was not OK)"); + } + Ok(()) +}