From e9efbcca4df19a1d4fb6c5bea48b61e73e0da249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Sat, 3 Oct 2026 21:27:04 +0200 Subject: [PATCH] OTA design: glossary, ADR 0003 (own signature check), plan Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- CONTEXT.md | 17 ++++++++++ ...003-own-signature-check-not-secure-boot.md | 11 +++++++ docs/milestones/OTA.md | 33 +++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 docs/adr/0003-own-signature-check-not-secure-boot.md create mode 100644 docs/milestones/OTA.md diff --git a/CONTEXT.md b/CONTEXT.md index 298cf9c..66df5dc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -104,6 +104,22 @@ The Notification raised once per boot when the SD card passes 80% full. Selectin **Storage Clean-up**: The screen where the user deletes old Logs and Captures by category and age, with a preview of the space freed. Notes are never offered for deletion. +**Firmware Update**: +Installing a new firmware image without a USB cable: pushed over Wi-Fi from the developer's PC, or read from the SD card. +_Avoid_: flash, upgrade (alone) + +**Update File**: +One signed file (`.ota`) that carries a firmware version, its image and a signature. The same file works over Wi-Fi and from the SD card. Anything not signed with the project's key is refused. +_Avoid_: binary, bin + +**Probation**: +The state of newly installed firmware until it proves healthy: booted, UI drawn, Services started, 30 s without a crash, and Wi-Fi connected if it's configured. Then it's confirmed for good. +_Avoid_: trial, test mode + +**Rollback**: +Returning automatically to the previous firmware when new firmware resets or crashes during Probation. +_Avoid_: revert, downgrade (a downgrade is installing an older version on purpose) + ## Relationships - The **Launcher** starts **Apps**. Exactly one **App** is in the foreground. @@ -114,6 +130,7 @@ The screen where the user deletes old Logs and Captures by category and age, wit - A **Sweep** pauses the **Mesh Service**; a **Sniffer** does not. - Every transmission is bounded by the **Region** and its **Duty Cycle Budget**. - Past 90% SD usage, **Logs** stop being written; the remaining space is kept for **Captures**. Nothing is deleted without the user's confirmation. +- A **Firmware Update** installs an **Update File**; the new firmware runs on **Probation**, and fails back by **Rollback**. - A **Node** may be in several **Channels**. A **Direct Message** targets exactly one **Node**. ## Flagged ambiguities diff --git a/docs/adr/0003-own-signature-check-not-secure-boot.md b/docs/adr/0003-own-signature-check-not-secure-boot.md new file mode 100644 index 0000000..c6ffc07 --- /dev/null +++ b/docs/adr/0003-own-signature-check-not-secure-boot.md @@ -0,0 +1,11 @@ +# Signed Update Files checked by the firmware, not ESP32 Secure Boot + +Firmware Updates are accepted only when their Update File carries a valid ECDSA P-256 signature over the image's SHA-256. The firmware itself checks it, against a public key compiled into it, before switching the boot partition. The private key lives outside the repository, in `~/.config/roro9stack/ota-key.pem`. + +We chose this over the ESP32's hardware Secure Boot. Secure Boot is enforced by the chip, but it burns eFuses one-way: a mistake bricks the device, and the device can never run unsigned firmware again, which makes recovery over USB harder. On a single development device, a software check that refuses unsigned pushes is enough, and it stays reversible: a new firmware can carry a new public key. + +## Consequences + +- Someone with physical USB access can still flash anything. Only Wi-Fi and SD card updates are guarded. +- **Losing the private key** means the next update has to go over USB, carrying a new public key. +- P-256 rather than Ed25519, because the firmware's TLS library (mbedTLS) already verifies it, so it costs no extra code. diff --git a/docs/milestones/OTA.md b/docs/milestones/OTA.md new file mode 100644 index 0000000..1031a89 --- /dev/null +++ b/docs/milestones/OTA.md @@ -0,0 +1,33 @@ +# OTA — Firmware Updates over Wi-Fi and from the SD card + +**Goal:** install new firmware without a USB cable. Push it from the PC over Wi-Fi, or drop it on the SD card. Unsigned images are refused, and a broken update rolls back by itself. + +## Decisions (design round 2026-10-03) + +| # | Decision | +|---|---| +| Q52 | Two sources: **push over Wi-Fi** from the PC, and **from the SD card**. Pulling from Gitea releases is deferred. | +| Q53 | **Signed Update Files** (ECDSA P-256 over SHA-256). The private key stays in `~/.config/roro9stack/`, and the firmware embeds the public key (ADR 0003). | +| Q54 | The device **always listens** for pushes on the LAN while Wi-Fi is Connected, announced as `roro9stack-.local`. | +| Q55 | New firmware runs on **Probation**. It's confirmed once booted, UI drawn, Services started, 30 s without a crash, and Wi-Fi connected (if configured). Otherwise **Rollback**. A Toast reports either outcome. | +| Q56 | **Downgrades are allowed**, with "older than the installed version" shown. | +| Q57 | A valid push **installs right away**: progress screen, then reboot. The reboot waits for Text Entry to end, 60 s at most. | + +## Done when + +- `scripts/ota_keygen.sh` creates the key pair once. The public key is committed; the private key never is. +- `scripts/flash.sh --ota` builds, signs and pushes to `roro9stack-.local`. The device shows progress, reboots, and a Toast confirms the new version. +- An Update File with a bad signature, a truncated or corrupted image, or no signature is refused, and the device keeps running. +- Settings → About → **Update from SD** lists the `.ota` files in `/updates` and installs one. +- A firmware that crashes during Probation rolls back to the previous version, and says so after the reboot. + +## Work breakdown + +1. **Update File format** (host-tested): header (magic, format, version, image size, SHA-256), signature, image. A streaming parser that hashes as it goes and decides accept / refuse / downgrade. The signature verifier sits behind an interface, so tests can inject one. +2. **PC side:** key generation, `make_ota.py` (wraps `firmware.bin` into a signed `.ota`), and the push client. `flash.sh --ota` ties them together. +3. **Device:** the Update Service. + - A listener on TCP 3232 plus mDNS. + - Writes the image to the inactive app slot, with the ECDSA check through mbedTLS. + - A progress screen, and a reboot that waits out Text Entry. +4. **Probation and Rollback:** the health checks, confirming the image, and detecting a rollback after reboot to report it. +5. **Update from SD:** the same parser, fed from the Storage Service's task (all card access stays there).