From 1928dcfd472597bf4a89904914cb7489b68ec89f Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Sat, 2 May 2026 16:14:26 -0400 Subject: [PATCH] Provision Wi-Fi creds + trust roots via NVS instead of compile-time embed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements provisioning-plan.md. The OTA-distributed firmware no longer contains any secrets or per-device config — those live in NVS and are written via USB by `make provision` from a (gitignored) `provisioning.toml`. Same firmware bytes can run on any device. Firmware changes: - src/main.rs reads wifi/ssid + wifi/pass from NVS namespace `wifi`. Strict-inert (sit-and-log) when missing; no compile-time fallback. WIFI_SSID/WIFI_PASS env! macros gone. - src/trust.rs becomes a `TrustConfig::load(nvs)` loader. Identities come from NVS namespace `trust`, key `identities` (JSON-encoded array). Sigstore root + intermediate PEMs come from `trust/fulcio_root` and `trust/fulcio_inter` blobs. include_str! gone. - src/sig.rs and src/ota.rs thread &TrustConfig through verify_bundle and the OTA loop instead of reading global consts. - build.rs no longer tracks WIFI_* env changes. New tool: - tools/provision/ host-side cargo crate. Reads provisioning.toml, emits an NVS CSV, shells out to ESP-IDF's nvs_partition_gen.py to produce a binary NVS image, optionally `espflash write-bin`'s it. Make targets: - `make provision` — build NVS image + flash it. - `make bootstrap` — flash-all + provision (new device setup). - `make build` no longer requires wifi.env. - wifi.env removed from prereqs / WIFI_ENV variable removed. Workflows: - ci.yml: drops the placeholder wifi.env step, adds a `build provision` job alongside the firmware + publisher jobs. - publish.yml: drops WIFI_SSID/WIFI_PASS secret reads. Only secret needed is the auto-injected GITHUB_TOKEN. Docs: - README updated to use `make bootstrap` + `make provision` flow. Project layout moved to a new repo-local CLAUDE.md. - provisioning.toml.example committed as the template the user copies. - wifi.env.example removed (no longer used). Migration: existing devices need `make bootstrap` over USB. The new firmware has no embedded creds; OTAing to it without provisioning would just sit in strict-inert. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 29 +- .github/workflows/publish.yml | 25 +- .gitignore | 8 +- CLAUDE.md | 38 ++ Makefile | 110 ++++-- README.md | 35 +- build.rs | 6 +- logs-plan.md | 222 ++++++++++++ provisioning.toml.example | 29 ++ src/main.rs | 74 +++- src/ota.rs | 18 +- src/sig.rs | 31 +- src/trust.rs | 115 ++++-- tools/provision/.cargo/config.toml | 6 + tools/provision/Cargo.lock | 533 ++++++++++++++++++++++++++++ tools/provision/Cargo.toml | 19 + tools/provision/rust-toolchain.toml | 3 + tools/provision/src/main.rs | 251 +++++++++++++ 18 files changed, 1402 insertions(+), 150 deletions(-) create mode 100644 CLAUDE.md create mode 100644 logs-plan.md create mode 100644 provisioning.toml.example create mode 100644 tools/provision/.cargo/config.toml create mode 100644 tools/provision/Cargo.lock create mode 100644 tools/provision/Cargo.toml create mode 100644 tools/provision/rust-toolchain.toml create mode 100644 tools/provision/src/main.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb3e8a3..8e96a97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,18 +54,10 @@ jobs: with: shared-key: ci-firmware - - name: Write placeholder wifi.env - # The firmware embeds WIFI_SSID/WIFI_PASS at compile time via - # env!() but doesn't validate them. CI just needs the build to - # succeed; runtime Wi-Fi connect with these placeholders would - # fail, but that's not what CI is testing. - run: | - { - echo 'export WIFI_SSID="ci-placeholder"' - echo 'export WIFI_PASS="ci-placeholder"' - } > wifi.env - - name: Build firmware + # No wifi.env needed — the firmware reads Wi-Fi creds from NVS + # at runtime, not from env!() at compile time. The published + # OCI image is device-agnostic. run: make build publisher: @@ -85,3 +77,18 @@ jobs: # `target = "xtensa-esp32-espidf"`. Hardcoded triple is fine # because we control the runner (ubuntu-latest = x86_64-linux). run: cd tools/publisher && cargo build --release --target x86_64-unknown-linux-gnu + + provision: + name: build provision + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: tools/provision + shared-key: ci-provision + + - name: Build provision + run: cd tools/provision && cargo build --release --target x86_64-unknown-linux-gnu diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8454359..0b37504 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,12 +2,14 @@ name: Publish OTA # Build the firmware on every push to main, push it as an OCI artifact # to ghcr.io/imjasonh/esp32, and sign it keylessly with cosign using -# the workflow's ambient OIDC token. The on-device verifier in -# src/trust.rs trusts this workflow's identity for OTA updates. +# the workflow's ambient OIDC token. The on-device verifier (src/sig.rs) +# trusts this workflow's identity (provisioned into the device's NVS). # -# Required repo secrets: -# WIFI_SSID, WIFI_PASS — embedded into the firmware at compile time -# (see src/main.rs env!()). The GITHUB_TOKEN is auto-provided. +# Published image is device-agnostic: contains no Wi-Fi creds, no +# trust roots, no GCP keys. Per-device config lives in NVS and is +# written via USB by `make provision`. See provisioning-plan.md. +# +# Only secret needed: GITHUB_TOKEN (auto-provided by the runner). on: push: @@ -71,19 +73,14 @@ jobs: tools/publisher shared-key: publish - - name: Write env files from secrets + - name: Write gh.env from GITHUB_TOKEN env: - WIFI_SSID: ${{ secrets.WIFI_SSID }} - WIFI_PASS: ${{ secrets.WIFI_PASS }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # `make publish` sources gh.env for cosign's registry auth. + # %q escapes for safe sourcing regardless of special chars. run: | - # %q escapes for safe sourcing regardless of special chars. - { - printf 'export WIFI_SSID=%q\n' "$WIFI_SSID" - printf 'export WIFI_PASS=%q\n' "$WIFI_PASS" - } > wifi.env printf 'export GH_TOKEN=%q\n' "$GH_TOKEN" > gh.env - chmod 600 wifi.env gh.env + chmod 600 gh.env - name: Build, push, and sign env: diff --git a/.gitignore b/.gitignore index 3abef47..e50a1c4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,11 @@ /.embuild wifi.env gh.env +provisioning.toml sdkconfig.defaults tools/publisher/target +tools/provision/target -# Cargo.lock IS tracked: both crates here are binaries (firmware and -# publisher), and binary Cargo.lock files should be committed for -# reproducible builds. +# Cargo.lock IS tracked: all crates here are binaries (firmware, +# publisher, provision), and binary Cargo.lock files should be +# committed for reproducible builds. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..32fefd6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ +# Project notes for Claude + +## Project layout + +``` +src/main.rs firmware entrypoint, Wi-Fi + HTTPS + OTA loop +src/ota.rs OTA polling, manifest fetch, blob streaming +src/sig.rs cosign Sigstore Bundle verification +src/trust.rs NVS-loaded trust config (identities + Sigstore CAs) +trust/ Sigstore Fulcio root + intermediate certs (provisioned) +tools/publisher/ host-side tool that pushes signed OCI artifacts +tools/provision/ host-side tool that builds + flashes NVS partition +.github/workflows/ ci.yml (PRs) + publish.yml (push to main) +Cargo.toml firmware deps +partitions.csv OTA-capable partition table (1.94 MB app slots) +sdkconfig.defaults.in ESP-IDF kconfig (Makefile substitutes paths) +Makefile build / flash / monitor / provision / publish entrypoints +provisioning.toml.example template for per-device NVS values +ota.md full OTA system documentation +provisioning-plan.md provisioning design + future Secure Boot v2 work +eink-plan.md planned e-ink display work +logs-plan.md planned GCP Cloud Logging integration +notes.txt internal design notes + setup gotchas (Python 3.12 + shim, partition-table CMake quirk, etc.) +``` + +## Conventions established in this repo + +- **Plans live in the repo** as `*-plan.md` (per-feature) and `ota.md` + (descriptive doc once a system is operational). Not in Claude memory. +- **Cargo.lock IS tracked** for all three crates here — they're all + binaries. +- **Don't use "and" in Make target names** — chain existing targets + instead (`make publish monitor`, not `make publish-and-monitor`). +- **Env-setup scripts** (e.g. espup's `export-esp.sh`) live in the + project dir, not `~`. +- **GHCR auth** uses classic PATs with `write:packages` (fine-grained + PATs don't expose a Packages permission for user-owned packages). diff --git a/Makefile b/Makefile index 573b9e0..cc5ba8c 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,25 @@ # ESP32 Rust — runnable documentation for build / flash / monitor. # See notes.txt for one-time setup (espup, brew deps, Python 3.12 shim). # -# Quick start: -# make wifi.env # one-time: copy wifi.env.example -> wifi.env -# $EDITOR wifi.env # fill in WIFI_SSID and WIFI_PASS -# make run # build, flash, open serial monitor +# Quick start (new device): +# make provisioning.toml # one-time: copy template +# $EDITOR provisioning.toml # fill in wifi creds, trust identities +# make bootstrap # full reflash + write NVS partition +# make monitor # watch it boot + connect PORT ?= /dev/cu.usbserial-0001 BIN := target/xtensa-esp32-espidf/release/esp32-blinky -# Host triple for building the publisher tool. Detected via rustc so this -# works on any developer machine without hardcoding aarch64-apple-darwin. +# Host triple for building host-side tools (publisher, provision). HOST_TRIPLE := $(shell rustc -vV | sed -n 's/^host: //p') # App-only firmware binary (no bootloader / partition table) suitable for # pushing as the OTA artifact layer. FW_BIN := target/firmware.bin +# Generated NVS partition image written by `tools/provision/`. +NVS_BIN := target/nvs.bin + # OCI artifact destination. OCI_REPO ?= ghcr.io/imjasonh/esp32 @@ -46,29 +49,29 @@ PYTHON_SHIM := $(CURDIR)/.embuild/python-shim export LIBCLANG_PATH export PATH := $(PYTHON_SHIM):$(XTENSA_GCC_BIN):$(PATH) -# Wi-Fi credentials (gitignored). Sourced inline because wifi.env uses -# bash `export VAR="value"` syntax, not Make assignment syntax. -WIFI_ENV := . ./wifi.env - -.PHONY: help build flash flash-all monitor run clean ensure-python-shim save-image publish pull-verify check-gh-env +.PHONY: help build flash flash-all monitor run clean ensure-python-shim \ + save-image publish pull-verify check-gh-env \ + provision provision-build bootstrap help: @echo "Targets:" - @echo " make build Compile firmware (requires wifi.env)" - @echo " make flash Build (if needed) and flash app to $(PORT)" - @echo " make flash-all Erase + write bootloader, partition table, app" - @echo " make monitor Open serial monitor on $(PORT) (Ctrl+C to exit)" - @echo " make run Build + flash + monitor" - @echo " make clean cargo clean" - @echo " make wifi.env Create wifi.env from template" - @echo " make save-image Convert ELF -> app-only .bin (target/firmware.bin)" - @echo " make publish Build, save-image, push OCI artifact to OCI_REPO" - @echo " make pull-verify Pull from OCI_REPO and check layer matches local .bin" + @echo " make build Compile firmware" + @echo " make flash Build (if needed) and flash app to $(PORT)" + @echo " make flash-all Erase + write bootloader, partition table, app" + @echo " make monitor Open serial monitor on $(PORT) (Ctrl+C to exit)" + @echo " make run Build + flash + monitor" + @echo " make clean cargo clean" + @echo " make save-image Convert ELF -> app-only .bin (target/firmware.bin)" + @echo " make publish Build, save-image, push OCI artifact to OCI_REPO" + @echo " make pull-verify Pull from OCI_REPO and check layer matches local .bin" + @echo "" + @echo " make provision Build NVS image from provisioning.toml + flash to device" + @echo " make bootstrap flash-all + provision (new device setup)" @echo "" @echo "Override the port: make flash PORT=/dev/cu.usbserial-XXXX" -build: wifi.env ensure-python-shim sdkconfig.defaults - $(WIFI_ENV) && cargo build --release +build: ensure-python-shim sdkconfig.defaults + cargo build --release # sdkconfig.defaults is generated from the .in template so we can substitute # the absolute project path into CONFIG_PARTITION_TABLE_CUSTOM_FILENAME @@ -98,9 +101,10 @@ flash: build espflash flash --port $(PORT) $(BIN) # Full reflash: erase entire flash, then write bootloader + partition table -# + app. Use this when the partition table changes (phase 0 of OTA), or as -# the recovery path if both OTA slots are bad and anti-bricking failed. -# After erase, otadata is empty and the bootloader picks ota_0 by default. +# + app. Use this when the partition table changes, or as the recovery +# path if both OTA slots are bad and anti-bricking failed. After erase, +# otadata is empty and the bootloader picks ota_0 by default. NVS is also +# wiped, so the device will boot strict-inert until `make provision`. BOOTLOADER := $(firstword $(wildcard target/xtensa-esp32-espidf/release/build/esp-idf-sys-*/out/build/bootloader/bootloader.bin)) flash-all: build @@ -120,6 +124,46 @@ run: build clean: cargo clean +# Build NVS partition image from provisioning.toml AND flash it to the +# device over USB. Requires `make build` to have run at least once so +# embuild has cloned ESP-IDF (needed for nvs_partition_gen.py). +provision: provisioning.toml build + cd tools/provision && cargo run --release --target $(HOST_TRIPLE) -- \ + --config $(CURDIR)/provisioning.toml \ + --out $(CURDIR)/$(NVS_BIN) \ + --nvs-gen $(NVS_GEN_PY) \ + --python $(NVS_GEN_PYTHON) \ + --port $(PORT) \ + --flash + +# Build the NVS image without flashing (useful for inspection / CI). +provision-build: provisioning.toml build + cd tools/provision && cargo run --release --target $(HOST_TRIPLE) -- \ + --config $(CURDIR)/provisioning.toml \ + --out $(CURDIR)/$(NVS_BIN) \ + --nvs-gen $(NVS_GEN_PY) \ + --python $(NVS_GEN_PYTHON) + +# ESP-IDF's NVS partition generator + the python venv to run it in. +# Both come from .embuild after `make build` has cloned ESP-IDF. +NVS_GEN_PY := $(CURDIR)/.embuild/espressif/esp-idf/v5.2.2/components/nvs_flash/nvs_partition_generator/nvs_partition_gen.py +NVS_GEN_PYTHON := $(CURDIR)/.embuild/espressif/python_env/idf5.2_py3.12_env/bin/python + +# Common new-device setup: full reflash + provision NVS. After this the +# device should boot, connect to Wi-Fi, and start polling for OTAs. +bootstrap: flash-all provision + +# If provisioning.toml exists, this target is up-to-date and the recipe +# doesn't run. If absent, copy the template, tell the user to edit it, +# and fail so they can't accidentally provision with placeholder values. +provisioning.toml: + @cp provisioning.toml.example provisioning.toml + @echo "" + @echo "Created provisioning.toml from provisioning.toml.example." + @echo "Edit it with your real Wi-Fi creds + trust identities, then re-run." + @echo "" + @exit 1 + # Convert the firmware ELF into an app-only .bin (no bootloader / partition # table). This is the format the OTA writer expects on the device, and what # we wrap as an OCI artifact layer for OTA distribution. @@ -144,8 +188,7 @@ publish: $(FW_BIN) check-gh-env cosign sign --yes $(OCI_REPO)@$$DIGEST # Pull the latest artifact from OCI_REPO and verify its layer SHA matches -# our locally-built firmware. Confirms the round trip works and exercises -# the same oci-distribution API the device will use in phase 2. +# our locally-built firmware. Confirms the round trip works. pull-verify: $(FW_BIN) check-gh-env . ./gh.env && cd tools/publisher && cargo run --release --target $(HOST_TRIPLE) -- pull-verify \ --repo $(OCI_REPO) \ @@ -161,14 +204,3 @@ check-gh-env: echo "See README.md for how to mint a classic PAT with write:packages."; \ exit 1; \ } - -# If wifi.env exists, this target is up-to-date and the recipe doesn't run. -# If it doesn't exist, copy the template, tell the user to edit it, and -# fail so they can't accidentally flash with placeholder creds. -wifi.env: - @cp wifi.env.example wifi.env - @echo "" - @echo "Created wifi.env from wifi.env.example." - @echo "Edit it with your real Wi-Fi SSID and password, then re-run." - @echo "" - @exit 1 diff --git a/README.md b/README.md index da68d8c..942ce08 100644 --- a/README.md +++ b/README.md @@ -43,19 +43,27 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # if you don't have uv ## First flash (USB) ```bash -make wifi.env # creates wifi.env from template -$EDITOR wifi.env # fill in WIFI_SSID and WIFI_PASS -make flash-all run # full erase + bootloader + partition + app, then monitor +make provisioning.toml # creates from template +$EDITOR provisioning.toml # fill in wifi creds + trust identities +make bootstrap # build, flash everything, write NVS +make monitor # watch it boot and connect ``` The first build clones ESP-IDF v5.2.2 into `.embuild/` (5–10 min). Subsequent builds are fast. +The OTA-distributed firmware **contains no secrets** — Wi-Fi creds and +trust roots live in NVS, written via USB by `make provision`. See +[`provisioning-plan.md`](provisioning-plan.md) for the full design. + ## Day-to-day ``` -make build Compile (requires wifi.env) +make build Compile firmware make flash Build + flash app (use flash-all after partitions change) +make flash-all Erase + write bootloader, partition table, app +make provision Write NVS partition from provisioning.toml over USB +make bootstrap flash-all + provision (new device setup) make monitor Open serial monitor; Ctrl+C to exit make run Build + flash + monitor make publish Build, push OCI artifact to ghcr.io/imjasonh/esp32, cosign sign @@ -67,22 +75,3 @@ PAT setup) and a real cosign OIDC flow the first time per ~10min window — a browser pops to authenticate. CI does this automatically via the GitHub Actions workflow's ambient OIDC token. -## Project layout - -``` -src/main.rs firmware entrypoint, Wi-Fi + HTTPS + OTA loop -src/ota.rs OTA polling, manifest fetch, blob streaming -src/sig.rs cosign Sigstore Bundle verification -src/trust.rs compile-time allowlist of signer identities -trust/ bundled Sigstore Fulcio root + intermediate certs -tools/publisher/ host-side Rust tool that pushes signed OCI artifacts -.github/workflows/ ci.yml (PRs) + publish.yml (push to main) -Cargo.toml firmware deps -partitions.csv OTA-capable partition table (1.75 MB app slots) -sdkconfig.defaults.in ESP-IDF kconfig (Makefile substitutes paths) -Makefile build / flash / monitor / publish entrypoints -wifi.env.example template for compile-time Wi-Fi creds -eink-plan.md planned e-ink display work -ota-plan.md full OTA design + per-phase status -notes.txt internal design notes and setup gotchas -``` diff --git a/build.rs b/build.rs index 552b311..f5775ad 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,8 @@ fn main() { - println!("cargo:rerun-if-env-changed=WIFI_SSID"); - println!("cargo:rerun-if-env-changed=WIFI_PASS"); + // GIT_SHA stays in env! — it's not a secret and is useful in the + // boot summary log line for identifying which build is running. + // Wi-Fi creds, trust config, and (future) GCP creds all come from + // NVS now (see provisioning-plan.md). println!("cargo:rerun-if-env-changed=GIT_SHA"); embuild::espidf::sysenv::output(); } diff --git a/logs-plan.md b/logs-plan.md new file mode 100644 index 0000000..6624f34 --- /dev/null +++ b/logs-plan.md @@ -0,0 +1,222 @@ +# Cloud Logging — plan (issue #2) + +Ship structured logs from the ESP32 to Google Cloud Logging so we can +monitor + diagnose remotely without a serial cable. No prebuilt +GCP/auth libraries on-device — talk directly to the REST APIs. + +## Architecture + +``` +┌─────────── ESP32 ───────────┐ ┌─────────── GCP ───────────┐ +│ │ │ │ +│ tracing event ──┐ │ │ │ +│ tracing event ──┼─▶ buffer │ POST logs: │ logging.googleapis.com │ +│ tracing event ──┘ (ring) │ ──────────────▶│ /v2/entries:write │ +│ │ │ Bearer │ │ +│ ▼ │ │ │ +│ background │ POST oauth2: │ oauth2.googleapis.com │ +│ sender task │ ──────signed─▶│ /token (grant_type= │ +│ │ │ JWT (RS256) │ jwt-bearer) │ +│ ▼ │ ◀──────────────│ │ +│ HTTPS POST │ access_token │ │ +│ │ │ │ +└──────────────────────────────┘ └────────────────────────────┘ +``` + +A new tracing **layer** captures events into a bounded ring buffer in +RAM. A background pthread drains the buffer, batches entries, and +POSTs them to Cloud Logging using a cached OAuth2 access token. +Tokens are obtained via the standard service-account JWT bearer flow +and cached for their ~1-hour lifetime so we only do RSA signing once +per refresh. + +## Auth flow (no library) + +Service-account JWT → access-token → API call. Standard Google flow. + +1. **JWT.** Header `{"alg":"RS256","typ":"JWT","kid":""}`, + claims `{"iss":"","scope":"https://www.googleapis.com/auth/logging.write","aud":"https://oauth2.googleapis.com/token","iat":,"exp":}`. + Base64url-encode each, concatenate with `.`, sign with the SA's + RSA private key (RS256 = RSA-PKCS#1 v1.5 over SHA-256), append + `.`. +2. **Token exchange.** POST to `https://oauth2.googleapis.com/token` + with form body + `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=`. + Response: `{"access_token":"...","expires_in":3599,"token_type":"Bearer"}`. +3. **Cache.** Hold the access token in RAM with its expiry. Re-mint + only when expired (or close to). RSA signing is the expensive step + we want to avoid doing per-log. + +## Logging API call + +`POST https://logging.googleapis.com/v2/entries:write` with +`Authorization: Bearer ` and JSON body: + +```json +{ + "logName": "projects//logs/esp32-firmware", + "resource": { + "type": "generic_node", + "labels": { + "project_id": "", + "location": "global", + "namespace": "esp32", + "node_id": "" + } + }, + "entries": [ + { + "severity": "INFO", + "timestamp": "2026-05-02T19:45:00Z", + "jsonPayload": { + "message": "ota: boot summary", + "fw": "979c862", + "repo": "ghcr.io/imjasonh/esp32", + "tag": "latest", + "poll_secs": 60, + "last_digest": "sha256:..." + } + }, + ... + ] +} +``` + +Entries are batched per POST. Resource labels carry device identity +(MAC). `jsonPayload` carries every tracing event field as a top-level +JSON key — preserves the structured fields tracing already emits. + +## On-device implementation + +### Tracing capture (`src/cloud_log.rs`, new) + +A `tracing_subscriber::Layer` that intercepts events. For each event: +- Capture severity (`INFO`/`WARN`/`ERROR`/`DEBUG`/`TRACE` mapped from tracing levels). +- Capture all structured fields into a `serde_json::Map` (or write + directly to a String at capture time to avoid a serde dep at this + layer). +- Take a wall-clock timestamp (requires NTP — see below). +- Push onto a bounded `crossbeam` channel or `heapless::spsc` queue. + If full, drop oldest (or newest) and bump a dropped-counter; we + log the drop count in the next batch so it shows up in Cloud + Logging. + +This layer composes with the existing log bridge (tracing → +EspLogger → serial) — events go to both serial and the cloud queue. + +### Sender task + +Background pthread, ~32 KB stack: + +1. Wait for Wi-Fi up. +2. Loop: + - Sleep `flush_interval` (default 5s). + - Drain queue (up to N entries or M bytes per batch). + - If empty, continue. + - Ensure access token is valid; refresh via JWT exchange if needed. + - POST batch to `logging.googleapis.com`. On 4xx → log to serial, + drop the batch. On 5xx / network error → keep batch, retry with + backoff (similar to the OTA loop). + +### Time sync (NTP) + +Cloud Logging entries need real timestamps; without NTP our system +clock starts at 1970. Add an SNTP sync at boot via +`esp-idf-svc::sntp::EspSntp` (synchronous) before the sender task +starts. If sync fails, sender task falls back to omitting `timestamp` +so Cloud Logging assigns server-side time (less accurate but valid). + +### Crates and footprint + +- `rsa` (or mbedtls FFI) — RSA-PKCS#1 v1.5 signing for JWT. ~50 KB. +- `base64` — already have. +- `sha2` — already have (used by RSA signing too). +- `serde_json` — already have. +- `time` — for RFC 3339 timestamp formatting. ~40 KB. +- Logging code itself: ~10 KB. + +Total budget estimate: ~100 KB additional firmware. We have 463 KB +free per OTA slot, so plenty of headroom. + +RAM: ring buffer (default ~4 KB), token cache (~2 KB), JWT scratch +(~1 KB) → ~10 KB total during steady state. + +## Compile-time configuration + +Following the existing `wifi.env` pattern, a new `gcp.env` (gitignored) +that the Makefile sources: + +``` +export GCP_PROJECT_ID="my-logs-project" +export GCP_SA_EMAIL="esp32-logger@my-logs-project.iam.gserviceaccount.com" +export GCP_SA_KEY_ID="abc..." +export GCP_SA_PRIVATE_KEY_PEM_PATH=./gcp-sa-key.pem +``` + +`build.rs` reads the PEM file and embeds the bytes into the firmware +via `env!()`/`include_bytes!()`. CI gets the same values from GHA +secrets and writes `gcp.env` at workflow time. + +## Concerns and questions for review + +### Concerns + +1. **Firmware is public on GHCR — anyone can pull it and extract the + SA private key.** This is the same threat model we already accept + for `WIFI_PASS`. Mitigations: + - **Scope the SA tightly.** Role: only `roles/logging.logWriter`. + Project: a dedicated logs-only GCP project with no other + resources. Worst case if leaked: someone fills our log buckets, + bounded by quota and our willingness to ignore them. + - **Alternative**: keep SA creds out of firmware; provision via + NVS over USB on first boot (parallels what we already deferred + for OTA repo/tag). More work, eliminates the leak. + +2. **RSA-2048 signing on ESP32 is slow** — ~hundreds of ms per JWT. + Cached for 1h, so amortized cost is negligible. But the first-time + sign blocks the sender task for noticeable time. Acceptable. + +3. **Log volume + cost.** If we ship every tracing event we could + easily hit Cloud Logging quotas or bill. Default to filtering at + `INFO` or higher for cloud, leave `DEBUG` for serial only. Make + the threshold an NVS-configurable knob (similar to `poll_secs`). + +4. **Wi-Fi outages / device offline.** Sender task buffers with a + bounded queue, drops oldest when full, reports drop count in next + batch. Long offline period = lossy. Acceptable for a monitoring + channel; not a record of truth. + +5. **NTP dependency.** We don't currently sync time, so adding SNTP + is part of this work. SNTP fail → use server-side timestamps. + +### Questions + +- **GCP project**: do you have one already, or should this plan + include creating a dedicated logs project? +- **Severity threshold default**: cloud-ship `INFO`+ only? + `WARN`+? Configurable via NVS? +- **Provisioning model**: bake the SA key in firmware (matches + current `wifi.env` pattern, accepts the public-firmware leak), or + do NVS-via-USB (more work, no leak, but requires touching a dev + workflow we haven't built)? +- **Pure-Rust `rsa` crate or mbedtls FFI?** Same trade-off we + discussed for cosign verification: pure-Rust is +50 KB, mbedtls + FFI saves the size at the cost of some unsafe code. Probably go + pure-Rust for first cut given current 463 KB headroom. +- **Batch size + flush interval?** I proposed 5s / batch, no firm + upper bound on entry count. Tuneable via NVS later. +- **Log name schema**: `projects//logs/esp32-firmware` per device, + or per chip MAC, or per app component (`esp32-firmware/ota`, + `esp32-firmware/wifi`)? + +## Future work (not in v1) + +- **Trace export** (full structured spans, not just events) — would + let us see request flows and timing. +- **Metrics export** to Cloud Monitoring — counters for OTA verifies, + poll cycles, Wi-Fi reconnects. +- **Alerting** based on log-based metrics (e.g., page on >5 OTA + verify failures in 1h). +- **OIDC instead of SA key** — would eliminate the key-leak concern, + but ESP32 doesn't have a way to get a usable OIDC token from + anywhere right now. diff --git a/provisioning.toml.example b/provisioning.toml.example new file mode 100644 index 0000000..da1ea52 --- /dev/null +++ b/provisioning.toml.example @@ -0,0 +1,29 @@ +# Provisioning config for `make provision`. Copy to `provisioning.toml` +# (gitignored) and fill in your values, then `make provision` to write +# everything to the device's NVS partition over USB. + +[wifi] +ssid = "your-wifi-ssid" +pass = "your-wifi-password" + +[trust] +# Bundled Sigstore Fulcio root + intermediate CA PEMs. These come from +# https://github.com/sigstore/root-signing/tree/main/targets and need to +# be re-fetched + re-provisioned if Sigstore rotates them (rare). +fulcio_root_pem = "trust/fulcio_root.pem" +fulcio_intermediate_pem = "trust/fulcio_intermediate.pem" + +# Allowlist of (identity, issuer) pairs the OTA verifier accepts as +# signers of incoming firmware. Identity is the SAN value in the Fulcio +# leaf cert — an rfc822Name (email) for OIDC issuers like Google, or a +# URI for workflow-based issuers like GitHub Actions. + +# Manual `make publish` from a developer machine. +[[trust.identities]] +identity = "imjasonh@gmail.com" +issuer = "https://accounts.google.com" + +# Automated publishes from the GHA workflow on push to main. +[[trust.identities]] +identity = "https://github.com/imjasonh/esp32/.github/workflows/publish.yml@refs/heads/main" +issuer = "https://token.actions.githubusercontent.com" diff --git a/src/main.rs b/src/main.rs index 1d7e6e9..18fe0c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use esp_idf_svc::hal::peripherals::Peripherals; use embedded_svc::http::client::Client; use esp_idf_svc::http::client::{Configuration as HttpConfig, EspHttpConnection}; use esp_idf_svc::http::Method; -use esp_idf_svc::nvs::EspDefaultNvsPartition; +use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs}; use esp_idf_svc::wifi::{ AuthMethod, BlockingWifi, ClientConfiguration, Configuration as WifiConfig, EspWifi, }; @@ -15,8 +15,12 @@ mod ota; mod sig; mod trust; -const SSID: &str = env!("WIFI_SSID"); -const PASS: &str = env!("WIFI_PASS"); +// NVS schema (must match what tools/provision/ writes): +// namespace=wifi key=ssid (str), key=pass (str) +const NVS_WIFI_NS: &str = "wifi"; +const NVS_WIFI_SSID: &str = "ssid"; +const NVS_WIFI_PASS: &str = "pass"; + // Set by the Makefile from `git rev-parse --short HEAD` and baked into // the firmware so each build identifies itself in the boot log. const FW_VERSION: &str = env!("GIT_SHA"); @@ -37,12 +41,29 @@ fn main() -> Result<()> { let sysloop = EspSystemEventLoop::take()?; let nvs = EspDefaultNvsPartition::take()?; + // Read all required NVS config up front; strict-inert if anything is + // missing. See provisioning-plan.md. + let (ssid, pass) = match read_wifi_creds(nvs.clone())? { + Some(creds) => creds, + None => block_unprovisioned("wifi/ssid or wifi/pass missing in NVS"), + }; + let trust = match trust::TrustConfig::load(nvs.clone())? { + Some(t) => t, + None => block_unprovisioned( + "trust/identities or trust/fulcio_{root,inter} missing in NVS", + ), + }; + tracing::info!( + identities = trust.identities.len(), + "loaded trust config from NVS", + ); + let mut wifi = BlockingWifi::wrap( EspWifi::new(peripherals.modem, sysloop.clone(), Some(nvs.clone()))?, sysloop, )?; - connect_wifi(&mut wifi)?; + connect_wifi(&mut wifi, &ssid, &pass)?; let ip_info = wifi.wifi().sta_netif().get_ip_info()?; tracing::info!( @@ -67,12 +88,13 @@ fn main() -> Result<()> { } let ota_nvs = nvs.clone(); + let ota_trust = trust.clone(); std::thread::Builder::new() // HTTPS + JSON + SHA256 is ~32 KB; phase 4a adds X.509 parsing // and ECDSA P-256/P-384 verification on top, which want more. // 48 KB is observed-safe with headroom. .stack_size(48 * 1024) - .spawn(move || ota::run(ota_nvs, FW_VERSION)) + .spawn(move || ota::run(ota_nvs, FW_VERSION, ota_trust)) .expect("spawn ota thread"); tracing::info!("main: idling, OTA loop running in background"); @@ -98,29 +120,61 @@ fn log_running_partition() { } } -fn connect_wifi(wifi: &mut BlockingWifi>) -> Result<()> { - let auth_method = if PASS.is_empty() { +fn connect_wifi(wifi: &mut BlockingWifi>, ssid: &str, pass: &str) -> Result<()> { + let auth_method = if pass.is_empty() { AuthMethod::None } else { AuthMethod::WPA2Personal }; wifi.set_configuration(&WifiConfig::Client(ClientConfiguration { - ssid: SSID.try_into().map_err(|_| anyhow!("SSID too long (max 32 bytes)"))?, + ssid: ssid.try_into().map_err(|_| anyhow!("SSID too long (max 32 bytes)"))?, bssid: None, auth_method, - password: PASS.try_into().map_err(|_| anyhow!("password too long (max 64 bytes)"))?, + password: pass.try_into().map_err(|_| anyhow!("password too long (max 64 bytes)"))?, channel: None, ..Default::default() }))?; wifi.start()?; - tracing::info!(ssid = SSID, "wifi started; connecting"); + tracing::info!(ssid = ssid, "wifi started; connecting"); wifi.connect()?; wifi.wait_netif_up()?; Ok(()) } +/// Read Wi-Fi credentials from NVS. Returns None if either key is missing. +fn read_wifi_creds(partition: EspDefaultNvsPartition) -> Result> { + let nvs = EspNvs::new(partition, NVS_WIFI_NS, false) + .map_err(|e| anyhow!("open NVS namespace {}: {:?}", NVS_WIFI_NS, e))?; + let mut ssid_buf = [0u8; 64]; + let mut pass_buf = [0u8; 96]; + let ssid = nvs + .get_str(NVS_WIFI_SSID, &mut ssid_buf) + .map_err(|e| anyhow!("read NVS {}/{}: {:?}", NVS_WIFI_NS, NVS_WIFI_SSID, e))?; + let pass = nvs + .get_str(NVS_WIFI_PASS, &mut pass_buf) + .map_err(|e| anyhow!("read NVS {}/{}: {:?}", NVS_WIFI_NS, NVS_WIFI_PASS, e))?; + match (ssid, pass) { + (Some(s), Some(p)) => Ok(Some((s.to_string(), p.to_string()))), + _ => Ok(None), + } +} + +/// Sit forever logging that the device is unprovisioned. Doesn't reboot +/// (the firmware itself is healthy, just needs config), so a power cycle +/// in PENDING_VERIFY state will roll back as a safety net. +fn block_unprovisioned(reason: &str) -> ! { + loop { + tracing::error!( + reason = reason, + "NOT PROVISIONED — run `make provision` to write NVS, then reboot", + ); + std::thread::sleep(Duration::from_secs(30)); + } +} + + fn fetch(url: &str) -> Result<()> { let conn = EspHttpConnection::new(&HttpConfig { crt_bundle_attach: Some(esp_idf_svc::sys::esp_crt_bundle_attach), diff --git a/src/ota.rs b/src/ota.rs index a3c2a26..e20e017 100644 --- a/src/ota.rs +++ b/src/ota.rs @@ -84,7 +84,13 @@ struct TokenResponse { /// Background loop. Runs forever; never returns. Spawn in a thread. /// Reads configuration from NVS on startup, with compile-time defaults. -pub fn run(nvs_partition: EspDefaultNvsPartition, fw_version: &str) -> ! { +/// `trust` was loaded by main.rs at boot from NVS — passed in here so +/// we don't re-open the NVS namespace on every poll. +pub fn run( + nvs_partition: EspDefaultNvsPartition, + fw_version: &str, + trust: crate::trust::TrustConfig, +) -> ! { let mut nvs = match EspNvs::new(nvs_partition, NVS_NAMESPACE, true) { Ok(n) => n, Err(e) => { @@ -120,7 +126,7 @@ pub fn run(nvs_partition: EspDefaultNvsPartition, fw_version: &str) -> ! { "ota: sleeping", ); std::thread::sleep(sleep_for); - match poll_once(&mut nvs, &cfg) { + match poll_once(&mut nvs, &cfg, &trust) { Ok(PollOutcome::NoChange) => { consecutive_failures = 0; tracing::info!("ota: no change"); @@ -169,7 +175,11 @@ enum PollOutcome { Updated(String), } -fn poll_once(nvs: &mut EspNvs, cfg: &OtaConfig) -> Result { +fn poll_once( + nvs: &mut EspNvs, + cfg: &OtaConfig, + trust: &crate::trust::TrustConfig, +) -> Result { let token = fetch_anon_token(&cfg.repo)?; // Fetch manifest and compute its SHA256 — that's the manifest digest @@ -204,7 +214,7 @@ fn poll_once(nvs: &mut EspNvs, cfg: &OtaConfig) -> Result Result<()> { +pub fn verify_bundle( + bundle_json: &[u8], + expected_manifest_digest_hex: &str, + trust: &TrustConfig, +) -> Result<()> { let bundle: Bundle = serde_json::from_slice(bundle_json).context("parse bundle JSON")?; let cert_der = b64_std() @@ -82,15 +86,16 @@ pub fn verify_bundle(bundle_json: &[u8], expected_manifest_digest_hex: &str) -> let identity = extract_san_identity(&leaf).context("extract SAN identity")?; let issuer = extract_oidc_issuer_v1(&leaf).context("extract OIDC issuer")?; - if !trust::TRUSTED_IDENTITIES + if !trust + .identities .iter() - .any(|(id, iss)| *id == identity && *iss == issuer) + .any(|t| t.identity == identity && t.issuer == issuer) { bail!("untrusted identity: {} (issuer {})", identity, issuer); } tracing::info!(identity = %identity, issuer = %issuer, "ota: signer identity OK"); - verify_chain(&leaf).context("cert chain verification")?; + verify_chain(&leaf, trust).context("cert chain verification")?; tracing::info!("ota: cert chain to Sigstore root OK"); let sig_bytes = b64_std() @@ -193,20 +198,20 @@ fn extract_oidc_issuer_v1(cert: &Certificate) -> Result { bail!("no OIDC issuer (1.3.6.1.4.1.57264.1.1) extension") } -/// Verify leaf was signed by the bundled Sigstore intermediate, and -/// that the bundled intermediate was signed by the bundled root. -fn verify_chain(leaf: &Certificate) -> Result<()> { - let intermediate = pem_to_cert(trust::SIGSTORE_INTERMEDIATE_PEM)?; - let root = pem_to_cert(trust::SIGSTORE_ROOT_PEM)?; +/// Verify leaf was signed by the provisioned Sigstore intermediate, and +/// that the provisioned intermediate was signed by the provisioned root. +fn verify_chain(leaf: &Certificate, trust: &TrustConfig) -> Result<()> { + let intermediate = pem_to_cert(&trust.fulcio_intermediate_pem)?; + let root = pem_to_cert(&trust.fulcio_root_pem)?; verify_signed_by_p384(leaf, &intermediate).context("leaf -> intermediate")?; verify_signed_by_p384(&intermediate, &root).context("intermediate -> root")?; Ok(()) } -fn pem_to_cert(pem: &str) -> Result { - let (label, der) = x509_cert::der::pem::decode_vec(pem.as_bytes()) - .map_err(|e| anyhow!("decode PEM: {}", e))?; +fn pem_to_cert(pem: &[u8]) -> Result { + let (label, der) = + x509_cert::der::pem::decode_vec(pem).map_err(|e| anyhow!("decode PEM: {}", e))?; if label != "CERTIFICATE" { bail!("unexpected PEM label: {}", label); } diff --git a/src/trust.rs b/src/trust.rs index d5c340d..d8199b7 100644 --- a/src/trust.rs +++ b/src/trust.rs @@ -1,36 +1,89 @@ -//! Compile-time trust configuration for OTA signing verification. +//! Trust configuration loaded from NVS at boot. //! -//! These values cannot be changed via OTA — only by editing the source -//! and reflashing over USB. That's the whole point: a compromised -//! signing identity must not be able to update its own allowlist. +//! What used to be `const TRUSTED_IDENTITIES` and `include_str!`'d +//! Sigstore PEMs is now provisioned via USB into NVS (see +//! `provisioning-plan.md`). The OTA-distributed firmware contains +//! only the *code* that reads and uses these values, never the +//! values themselves — so the public OCI image is device-agnostic +//! and carries no policy data. +//! +//! Schema (must match what `tools/provision/` writes): +//! +//! namespace=trust +//! identities blob JSON: [{"identity":"...","issuer":"..."}, ...] +//! fulcio_root blob PEM bytes (Sigstore root CA) +//! fulcio_inter blob PEM bytes (Sigstore intermediate CA) -/// Allowlist of (identity, issuer) pairs the OTA verifier will accept -/// as signers. The identity is the Fulcio cert's SAN value — either an -/// rfc822Name (email, for OIDC issuers like Google) or a URI (for -/// workflow-based issuers like GitHub Actions). The issuer is read from -/// extension OID 1.3.6.1.4.1.57264.1.1 (the legacy Fulcio OIDC-issuer -/// extension), which carries the OIDC issuer URL. -pub const TRUSTED_IDENTITIES: &[(&str, &str)] = &[ - // Manual `make publish` from a developer's machine. Browser-based - // OIDC against Google. - ("imjasonh@gmail.com", "https://accounts.google.com"), +use anyhow::{anyhow, Context, Result}; +use esp_idf_svc::nvs::{EspDefaultNvsPartition, EspNvs}; +use serde::Deserialize; - // Automated publishes from the GitHub Actions workflow on push to - // main. The URI pins the exact workflow file at the exact branch; - // a malicious commit that adds a different workflow file won't - // produce a matching SAN URI. Trust here is bounded by who can - // push to imjasonh/esp32:main (i.e. the source-of-truth itself). - ( - "https://github.com/imjasonh/esp32/.github/workflows/publish.yml@refs/heads/main", - "https://token.actions.githubusercontent.com", - ), -]; +const NVS_TRUST_NS: &str = "trust"; +const NVS_IDENTITIES: &str = "identities"; +const NVS_FULCIO_ROOT: &str = "fulcio_root"; +const NVS_FULCIO_INTER: &str = "fulcio_inter"; -/// Sigstore Public Good Instance Fulcio intermediate CA (v1). Used to -/// verify the leaf signing cert was issued by Sigstore. -pub const SIGSTORE_INTERMEDIATE_PEM: &str = - include_str!("../trust/fulcio_intermediate.pem"); +/// One entry from the allowlist. The OTA verifier accepts a signature +/// only if the leaf cert's SAN value matches `identity` AND the OIDC +/// issuer extension matches `issuer`. +#[derive(Deserialize, Clone, Debug)] +pub struct TrustedIdentity { + pub identity: String, + pub issuer: String, +} -/// Sigstore Public Good Instance Fulcio root CA (v1). Used to verify -/// the bundled intermediate hasn't been swapped (defense in depth). -pub const SIGSTORE_ROOT_PEM: &str = include_str!("../trust/fulcio_root.pem"); +/// The full set of trust roots needed to verify a Sigstore bundle. +#[derive(Clone)] +pub struct TrustConfig { + pub identities: Vec, + pub fulcio_root_pem: Vec, + pub fulcio_intermediate_pem: Vec, +} + +impl TrustConfig { + /// Load from NVS. Returns `Ok(None)` if any required key is absent + /// (treat as "unprovisioned" — caller should refuse to verify). + pub fn load(partition: EspDefaultNvsPartition) -> Result> { + let nvs = EspNvs::new(partition, NVS_TRUST_NS, false) + .map_err(|e| anyhow!("open NVS namespace {}: {:?}", NVS_TRUST_NS, e))?; + + // PEMs are ~3KB; identities JSON is ~500 bytes. 4KB buffers + // give comfortable headroom for now. + let id_bytes = read_blob(&nvs, NVS_IDENTITIES, 4096)?; + let root_bytes = read_blob(&nvs, NVS_FULCIO_ROOT, 4096)?; + let inter_bytes = read_blob(&nvs, NVS_FULCIO_INTER, 4096)?; + + match (id_bytes, root_bytes, inter_bytes) { + (Some(id), Some(root), Some(inter)) => { + let identities: Vec = serde_json::from_slice(&id) + .context("parse trust/identities NVS blob as JSON")?; + if identities.is_empty() { + return Err(anyhow!( + "trust/identities is provisioned but contains no entries" + )); + } + Ok(Some(Self { + identities, + fulcio_root_pem: root, + fulcio_intermediate_pem: inter, + })) + } + _ => Ok(None), + } + } +} + +fn read_blob( + nvs: &EspNvs, + key: &str, + max_size: usize, +) -> Result>> { + let mut buf = vec![0u8; max_size]; + match nvs + .get_blob(key, &mut buf) + .map_err(|e| anyhow!("read NVS {}/{}: {:?}", NVS_TRUST_NS, key, e))? + { + Some(bytes) => Ok(Some(bytes.to_vec())), + None => Ok(None), + } +} diff --git a/tools/provision/.cargo/config.toml b/tools/provision/.cargo/config.toml new file mode 100644 index 0000000..7a1647b --- /dev/null +++ b/tools/provision/.cargo/config.toml @@ -0,0 +1,6 @@ +# Override the firmware project's parent .cargo/config.toml so this builds +# for the host. Same dance as tools/publisher — the parent has +# `target = "xtensa-esp32-espidf"` and `[unstable] build-std`, neither of +# which apply to a host tool. +[unstable] +build-std = [] diff --git a/tools/provision/Cargo.lock b/tools/provision/Cargo.lock new file mode 100644 index 0000000..4f54a4d --- /dev/null +++ b/tools/provision/Cargo.lock @@ -0,0 +1,533 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "provision" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "csv", + "serde", + "serde_json", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/provision/Cargo.toml b/tools/provision/Cargo.toml new file mode 100644 index 0000000..e7f6a94 --- /dev/null +++ b/tools/provision/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "provision" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "provision" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +serde_json = "1" +csv = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/tools/provision/rust-toolchain.toml b/tools/provision/rust-toolchain.toml new file mode 100644 index 0000000..c0227d9 --- /dev/null +++ b/tools/provision/rust-toolchain.toml @@ -0,0 +1,3 @@ +# Host build, not the xtensa firmware target. +[toolchain] +channel = "stable" diff --git a/tools/provision/src/main.rs b/tools/provision/src/main.rs new file mode 100644 index 0000000..318ebe2 --- /dev/null +++ b/tools/provision/src/main.rs @@ -0,0 +1,251 @@ +//! Build a binary NVS partition image from a `provisioning.toml` and +//! (optionally) flash it to the device. See `provisioning-plan.md`. +//! +//! The actual NVS binary format is generated by ESP-IDF's +//! `nvs_partition_gen.py` (already on disk inside `.embuild/`). We just +//! produce the CSV input it expects, then shell out. + +use anyhow::{anyhow, bail, Context, Result}; +use clap::Parser; +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Parser)] +#[command(version, about = "Build + flash NVS partition image from provisioning.toml")] +struct Cli { + /// Path to provisioning.toml. + #[arg(long, default_value = "provisioning.toml")] + config: PathBuf, + + /// Where to write the generated NVS .bin. + #[arg(long, default_value = "target/nvs.bin")] + out: PathBuf, + + /// NVS partition size in bytes. Must match `partitions.csv` (the `nvs` + /// row's size column). Default matches the current 0x6000 (24 KB) NVS. + #[arg(long, default_value_t = 0x6000)] + nvs_size: u32, + + /// Offset in flash to write to. Must match `partitions.csv` nvs offset. + #[arg(long, default_value = "0x9000")] + nvs_offset: String, + + /// If true, also flash the generated image to the device via espflash. + #[arg(long)] + flash: bool, + + /// Serial port for flashing. + #[arg(long, default_value = "/dev/cu.usbserial-0001")] + port: String, + + /// Path to ESP-IDF's nvs_partition_gen.py. Defaults to the in-repo + /// embuild copy that gets cloned by `make build`. + #[arg(long, default_value = ".embuild/espressif/esp-idf/v5.2.2/components/nvs_flash/nvs_partition_generator/nvs_partition_gen.py")] + nvs_gen: PathBuf, + + /// Path to the python interpreter to run nvs_partition_gen.py with. + /// Defaults to the embuild-bootstrapped 3.12 venv (matches what the + /// firmware build uses; ensures script deps are present). + #[arg(long, default_value = ".embuild/espressif/python_env/idf5.2_py3.12_env/bin/python")] + python: PathBuf, +} + +#[derive(Deserialize, Debug)] +struct ProvisioningConfig { + wifi: WifiConfig, + trust: TrustConfig, +} + +#[derive(Deserialize, Debug)] +struct WifiConfig { + ssid: String, + pass: String, +} + +#[derive(Deserialize, Debug)] +struct TrustConfig { + identities: Vec, + fulcio_root_pem: PathBuf, + fulcio_intermediate_pem: PathBuf, +} + +#[derive(Deserialize, serde::Serialize, Debug)] +struct TrustedIdentity { + identity: String, + issuer: String, +} + +fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let args = Cli::parse(); + + let toml_str = std::fs::read_to_string(&args.config) + .with_context(|| format!("read {}", args.config.display()))?; + let mut cfg: ProvisioningConfig = + toml::from_str(&toml_str).context("parse provisioning.toml")?; + + // Resolve PEM paths relative to the directory of provisioning.toml, + // so the user can write `fulcio_root_pem = "trust/fulcio_root.pem"` + // and it works regardless of where the tool is invoked from. + let config_dir = args + .config + .canonicalize() + .with_context(|| format!("canonicalize {}", args.config.display()))? + .parent() + .ok_or_else(|| anyhow!("no parent dir for {}", args.config.display()))? + .to_path_buf(); + if cfg.trust.fulcio_root_pem.is_relative() { + cfg.trust.fulcio_root_pem = config_dir.join(&cfg.trust.fulcio_root_pem); + } + if cfg.trust.fulcio_intermediate_pem.is_relative() { + cfg.trust.fulcio_intermediate_pem = + config_dir.join(&cfg.trust.fulcio_intermediate_pem); + } + + tracing::info!( + identities = cfg.trust.identities.len(), + ssid = cfg.wifi.ssid, + "loaded provisioning config", + ); + + if !args.nvs_gen.exists() { + bail!( + "{} not found — run `make build` once so embuild clones ESP-IDF", + args.nvs_gen.display() + ); + } + + // Stage the trust/identities JSON to a temp file (nvs_partition_gen + // reads `binary` values from disk, not stdin). + let target_dir = args + .out + .parent() + .unwrap_or_else(|| Path::new("target")) + .to_path_buf(); + std::fs::create_dir_all(&target_dir) + .with_context(|| format!("create {}", target_dir.display()))?; + let identities_json_path = target_dir.join("nvs-identities.json"); + let identities_json = + serde_json::to_vec(&cfg.trust.identities).context("serialize identities")?; + std::fs::write(&identities_json_path, &identities_json) + .with_context(|| format!("write {}", identities_json_path.display()))?; + tracing::info!( + path = %identities_json_path.display(), + bytes = identities_json.len(), + "wrote identities JSON", + ); + + // Build the NVS CSV. + let csv_path = target_dir.join("nvs.csv"); + write_csv( + &csv_path, + &cfg, + &identities_json_path, + ) + .context("write NVS CSV")?; + tracing::info!(path = %csv_path.display(), "wrote NVS CSV"); + + // Run nvs_partition_gen.py. + let status = Command::new(&args.python) + .arg(&args.nvs_gen) + .arg("generate") + .arg(&csv_path) + .arg(&args.out) + .arg(args.nvs_size.to_string()) + .status() + .with_context(|| format!("run {}", args.nvs_gen.display()))?; + if !status.success() { + bail!("nvs_partition_gen.py failed: {}", status); + } + let bin_size = std::fs::metadata(&args.out)?.len(); + tracing::info!( + path = %args.out.display(), + bytes = bin_size, + "generated NVS partition image", + ); + + if args.flash { + tracing::info!( + port = %args.port, + offset = %args.nvs_offset, + "flashing NVS image", + ); + let status = Command::new("espflash") + .args([ + "write-bin", + "--port", + &args.port, + &args.nvs_offset, + args.out.to_str().context("non-utf8 out path")?, + ]) + .status() + .context("run espflash write-bin")?; + if !status.success() { + bail!("espflash write-bin failed: {}", status); + } + tracing::info!("NVS partition flashed; reboot the device to pick it up"); + } else { + tracing::info!( + "build complete; pass --flash to write to the device, or run `make provision`", + ); + } + + Ok(()) +} + +fn write_csv( + path: &Path, + cfg: &ProvisioningConfig, + identities_path: &Path, +) -> Result<()> { + let mut wtr = csv::WriterBuilder::new() + .quote_style(csv::QuoteStyle::Necessary) + .from_path(path)?; + // Header + wtr.write_record(&["key", "type", "encoding", "value"])?; + + // wifi namespace + wtr.write_record(&["wifi", "namespace", "", ""])?; + wtr.write_record(&["ssid", "data", "string", &cfg.wifi.ssid])?; + wtr.write_record(&["pass", "data", "string", &cfg.wifi.pass])?; + + // trust namespace + wtr.write_record(&["trust", "namespace", "", ""])?; + wtr.write_record(&[ + "identities", + "file", + "binary", + identities_path + .to_str() + .ok_or_else(|| anyhow!("identities path not UTF-8"))?, + ])?; + wtr.write_record(&[ + "fulcio_root", + "file", + "binary", + cfg.trust + .fulcio_root_pem + .to_str() + .ok_or_else(|| anyhow!("fulcio_root_pem path not UTF-8"))?, + ])?; + wtr.write_record(&[ + "fulcio_inter", + "file", + "binary", + cfg.trust + .fulcio_intermediate_pem + .to_str() + .ok_or_else(|| anyhow!("fulcio_intermediate_pem path not UTF-8"))?, + ])?; + + wtr.flush()?; + Ok(()) +}