1
0
Fork 0
mirror of https://github.com/imjasonh/esp32 synced 2026-07-13 10:36:58 +00:00
Commit graph

14 commits

Author SHA1 Message Date
0e9559f375 CI: drop .embuild cache (corruption across runners), pass --target on publisher
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:27:42 -04:00
9ef15ba16d CI: pin setup-uv to v7 (no v8 moving tag), add publisher rust-toolchain
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:22:34 -04:00
71fa55f189 Add CI workflow, bump GHA actions, refresh + commit Cargo.lock
CI workflow (.github/workflows/ci.yml):
- Triggers on PRs (and main pushes for redundant fast feedback).
- Two parallel jobs: build the firmware (with placeholder wifi.env),
  build the host-side publisher tool. No publishing or signing -- that
  remains publish.yml's job.
- Per-PR concurrency: a new push to a branch cancels in-flight CI for
  the previous commit on that branch.

GHA action bumps (publish.yml + ci.yml both use the new versions):
- actions/checkout    v4 -> v6
- astral-sh/setup-uv  v3 -> v8
- esp-rs/xtensa-toolchain  v1.5 -> v1.7
- sigstore/cosign-installer  v3 -> v4 (defaults to cosign 3, which
  still produces the Sigstore Bundle v0.3 format we verify on-device)
- actions/cache       v4 -> v5
- Swatinem/rust-cache stays v2 (already current)

Gets us off the deprecated Node.js 20 actions runtime.

Cargo.lock: now tracked. Both crates (firmware and publisher) are
binaries, and binary Cargo.lock should be committed for reproducible
builds. `cargo update` was a no-op except for a couple transitive
patch bumps in the firmware (generic-array 0.14.7 -> 0.14.9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:16:55 -04:00
58343daf1c GHA: drop invalid @3 spec on cargo install espflash
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:02:13 -04:00
6cc6f38ce0 Auto-publish from GHA on push to main; sign by immutable digest
GitHub Actions workflow at .github/workflows/publish.yml builds the
firmware on every push to main, pushes it to ghcr.io/imjasonh/esp32,
and cosign-signs it keylessly using the workflow's ambient OIDC token.
Required repo secrets: WIFI_SSID, WIFI_PASS (the GITHUB_TOKEN is
injected automatically).

Trust changes:
- src/trust.rs adds the GHA workflow identity to TRUSTED_IDENTITIES,
  alongside the existing email entry. The SAN URI pins the exact
  workflow file at refs/heads/main; a malicious commit that adds a
  different workflow won't match.
- src/sig.rs's SAN extractor now also handles GeneralName::Uniform-
  ResourceIdentifier (workflow URIs), not just Rfc822Name (emails).

Sign-by-digest:
- tools/publisher prints `digest: sha256:...` on stdout (tracing logs
  redirected to stderr) so downstream tooling can target the immutable
  manifest digest.
- Makefile captures that digest and runs `cosign sign ... $REPO@DIGEST`
  instead of `:latest`, eliminating the tag-race window between push
  and sign.

Bootstrap: the device's currently-running firmware doesn't know the
GHA identity yet, so the first GHA-signed publish will be rejected.
A manual `make publish` after this lands rolls out the new
TRUSTED_IDENTITIES, after which GHA-signed updates verify cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:58:44 -04:00
25ac144222 Mark phase 4a done in ota-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:50:49 -04:00
5be27a560c Add cosign signature verification (OTA phase 4a)
Each OTA fetch is now gated on a Sigstore Bundle signature whose Fulcio
cert identifies a member of an allowlist hardcoded in src/trust.rs
(currently imjasonh@gmail.com / accounts.google.com). The allowlist
cannot be changed via OTA -- only by editing source and reflashing
over USB.

Publisher (Makefile): make publish now runs cosign sign --yes after
the publisher push. Cosign keyless OIDC pops a browser the first time;
subsequent signs reuse the cached token within ~10min.

Firmware:
- src/trust.rs: TRUSTED_IDENTITIES list + bundled Sigstore root and
  intermediate CA PEMs (trust/fulcio_root.pem, fulcio_intermediate.pem).
- src/sig.rs: parse Sigstore Bundle v0.3 (DSSE envelope), verify (a)
  Fulcio cert SAN email + OID 1.3.6.1.4.1.57264.1.1 issuer match the
  allowlist, (b) leaf cert chains to bundled Sigstore root via
  P-384 ECDSA-SHA384, (c) DSSE signature verifies via P-256 ECDSA-SHA256
  over the PAE, (d) in-toto Statement subject digest binds to our
  manifest digest.
- src/ota.rs: fetch_manifest now returns the manifest's own SHA256 (the
  digest cosign signed), not just the parsed body. New
  fetch_signature_bundle walks the OCI 1.1 referrers layout cosign uses
  (image index -> inner manifest -> bundle blob). Sig is fetched and
  verified before the firmware download starts.

Crates: p256, p384, x509-cert (with pem feature), base64. Adds
~350 KB to the firmware -- repartitioned ota slots from 1.5MB to
1.75MB to fit (USB-only migration). Bumped OTA thread stack to
48KB for cert-parsing headroom.

Verified end-to-end: Jason signed :latest with cosign keyless,
device polled, all four verification steps passed (identity, chain,
DSSE sig, in-toto binding), then downloaded and applied as before.

Phase 4b (Rekor SET / transparency log inclusion proof) and 4c
(operational hardening) remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:50:03 -04:00
1ab146d685 Migrate firmware logging from log to tracing
Swap `log = "0.4"` for `tracing = { version = "0.1", features = ["log"] }`.
The `log` feature makes tracing events emit log records when no subscriber
is set, so EspLogger (a log::Logger) keeps writing to ESP-IDF's log system.
No subscriber needed on the device.

Convert call sites in main.rs and src/ota.rs to tracing macros and
restructure the high-signal lines to use structured fields:

  ota: boot summary fw="b517946" repo=ghcr.io/imjasonh/esp32 tag=latest
                    poll_secs=60 last_digest=sha256:c7e09ea...
  ota: sleeping sleep_secs=63 failures=0
  ota: download progress written=262144 total=1272624

The bridged log records render fields as `key=value` (and `key="value"`
for Debug-formatted ones) appended to the message text -- greppable,
and ready for a real tracing subscriber if we want one later.

Verified end-to-end on the device: phase 3 image OTA'd to the tracing
build, boot summary logged with new field syntax.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:10:32 -04:00
b517946cce Add OTA operational glue (OTA phase 3, partial)
src/ota.rs:
- OtaConfig::load_from_nvs reads `repo` (str), `tag` (str), and
  `poll_secs` (u32) from the `ota` namespace, falling back to the
  compile-time defaults when keys are absent. Provisioning the values
  from outside the firmware is deferred (see ota-plan.md).
- Exponential backoff with +/- 10% jitter on registry errors:
  consecutive_failures grows on each failure, sleep is
  poll_interval * 2^failures capped at 1h. Jitter is also applied on
  the success path so a fleet doesn't poll in lockstep. Randomness
  via esp_random().
- Boot summary log: one line on startup with fw version, repo, tag,
  poll interval, and last applied digest. Easier to diagnose remote
  device state from a single grep.

main.rs passes FW_VERSION (the git SHA from env!) to ota::run, which
includes it in the boot summary. The OtaConfig parameter is gone --
ota::run loads from NVS itself.

ota-plan.md marks phase 3 done (partial); If-None-Match, force-update
GPIO, and an NVS provisioning mechanism explicitly deferred.

Verified end-to-end: published phase 3 image, watched the device pick
it up, boot into ota_1, mark valid, then log the boot summary. Two
consecutive sleeps fired at 64s and 66s for the same 60s base, proving
jitter is firing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:04:17 -04:00
a91cf62da6 Add device-side OTA loop (OTA phase 2)
src/ota.rs polls ghcr.io every 60s on a dedicated 32 KB pthread,
fetches the manifest with an anonymous Bearer token, and on a digest
mismatch streams the layer into the inactive OTA partition while
verifying SHA256 against the descriptor as it goes. Aborts and
bails on size or SHA mismatch; otherwise sets the boot partition,
persists pending_digest to NVS, and reboots.

main.rs detects PENDING_VERIFY on boot and only calls
esp_ota_mark_app_valid_cancel_rollback() after Wi-Fi + the existing
HTTPS bringup checks pass -- so an OTA that breaks networking
auto-reverts on the bootloader's next boot. After mark-valid,
pending_digest is promoted to last_digest in NVS.

GIT_SHA is baked into the firmware via env!() at compile time
(set by the Makefile from `git rev-parse --short HEAD`) and logged
on boot, so we can see which build is running.

Bumped CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT to 16 KB; 8 KB
stack-overflowed during HTTPS + JSON + SHA work.

`make monitor` now auto-passes --non-interactive when stdout is
not a TTY, so it works in scripts and background tasks.

Verified end-to-end against ghcr.io/imjasonh/esp32 -- published v1,
flashed via USB, bumped a visible version string, published v2,
and the device polled, downloaded, SHA-verified, rebooted, and
marked the new image valid after bringup. Zero USB intervention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:53:19 -04:00
2c3ea9380a Add OCI artifact publisher (OTA phase 1)
tools/publisher/ wraps a firmware .bin as an OCI artifact and pushes
to GHCR using oci-distribution. Mediatypes vnd.esp32.firmware.{v1+json,
bin}; manifest carries org.opencontainers.image.source so GHCR auto-
links the package on first push. Pushes both :latest and :sha-<short>.

A pull-verify subcommand fetches the manifest and layer back and checks
the SHA against a local file -- end-to-end verified pushes and pulls
match bit-for-bit. Same oci-distribution API will be reused for the
device-side fetch in phase 2.

Wired into Make as save-image / publish / pull-verify. GH_TOKEN sourced
from a gitignored gh.env (classic PAT with write:packages -- fine-
grained PATs don't expose a Packages permission for user-owned packages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:29:51 -04:00
0a5d0530d1 Switch to OTA partition layout and document Wi-Fi + HTTPS demo
2-slot OTA partition table (ota_0/ota_1, 1.5MB each) on 4MB flash.
sdkconfig.defaults is now generated by the Makefile from a template
so we can substitute the absolute path to partitions.csv -- IDF's
CMake resolves CONFIG_PARTITION_TABLE_CUSTOM_FILENAME relative to
embuild's synthetic project under target/, not our repo root.

Adds make flash-all for full erase + bootloader + partition table +
app reflash; doubles as USB recovery if both OTA slots end up bad.

Includes plans for e-ink display work (eink-plan.md) and OCI-based
OTA updates via GHCR (ota-plan.md). Phase 0 of OTA shipped: device
boots into ota_0, verified via esp_ota_get_running_partition() log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:14:21 -04:00
9d28a3b437 initial commit
Signed-off-by: Jason Hall <imjasonh@gmail.com>
2026-05-02 11:43:41 -04:00
6b132b9937
Initial commit 2026-05-02 11:43:06 -04:00