+++ title = "How an update works" description = "The signed Update File, the four ways to get one onto a device, Probation and Rollback, and the check that sits in front of all of them." weight = 3 [extra] tag = "Updates" diagrams = true +++ A **Firmware Update** installs one signed file, an **Update File** (`.ota`). Every way of delivering it ends at the same gate, and a new firmware must prove itself before it is kept. The decisions are [ADR 0003](/dev/decisions/0003-own-signature-check-not-secure-boot/) (the signature), [ADR 0005](/dev/decisions/0005-safe-mode-crash-reports-watchdog/) (when the new firmware crashes) and [ADR 0008](/dev/decisions/0008-ci-signs-releases/) (who signs releases). ## The Update File {{ diagram(src="update-file.svg", min_width=580, caption="A 160-byte header, then the image. Bytes 0 to 79 are signed.") }} - A **160-byte header**: the magic `RORO-OTA`, the format, the header size, the image size, the image's **SHA-256** and the version (bytes 0 to 79, the signed part), then the signature's length, the **signature** and reserved bytes. - The signature is **ECDSA P-256 over the SHA-256 of bytes 0 to 79**, checked by the firmware against a **public key compiled into it** (`keys/ota-public.pem`, committed), **before anything is written**. - Then the **image**, hashed while it is written; at the end the hash must equal the one in the header. Make, check and push one: ```sh scripts/ota_keygen.sh # once: creates the key pair. The private key goes to ~/.config/roro9stack/ota-key.pem (never committed); the public key to keys/ and the firmware source scripts/make_ota.py firmware.bin v0.12.0 out.ota # wraps and signs an image (the key: $RORO_OTA_KEY or the default path) scripts/ota_verify.py out.ota # checks a file the way a device does, on the PC scripts/ota_push.py out.ota 10.39.39.12 # pushes it to the Update Service, TCP 3232 scripts/flash.sh --ota 10.39.39.12 # builds, signs and pushes in one go (add --debug for a Debug Build) ``` `ota_push.py` prints the device's answer (`OK …`), or says the device **refused the update and hung up**, with the reason on the device's screen: the header is checked first, so a refused file stops mid-transfer. ## Four ways in, one gate {{ diagram(src="ways-in.svg", min_width=580, caption="Every path ends at the same Update Service and the same signature check.") }} 1. **Push over Wi-Fi** to TCP 3232: `scripts/flash.sh --ota`. The device always listens while Wi-Fi is connected. 2. **From the SD card**: a `.ota` in `/updates`, installed from Settings → Firmware, from the Storage App, or with the `install ` command. Put it there by hand, with `scripts/rdbg.py put` over Wi-Fi, or with `scripts/sd_put.sh` over USB. 3. **From the project's releases** on Gitea, which the device downloads itself over TLS (Settings → Firmware, or `update check` / `update install `): see [Flash and update](/dev/build/flash/). All three feed the same parser: the **header and signature are checked first**, the image is written to the **other app slot** while it is hashed, and the slot becomes the next boot **only if the hash matches**. Anything wrong ends in a Toast and **nothing changes**. A downgrade is allowed, and shows "older than the installed version". The device then restarts (waiting up to 60 seconds if you are typing) into **Probation**. ## Probation and Rollback {{ diagram(src="probation.svg", min_width=620, caption="The life of an update: install, restart, Probation, confirmed. A crash or a restart before the last step sends the device back to the previous firmware.") }} A new image is **not trusted** at first: 1. **Install:** the image goes to the other slot and the OTA data marks it *new*. 2. **Restart:** the bootloader turns *new* into *pending verify* and boots it. 3. **Probation:** the new firmware must boot, draw its UI, start its Services, run **30 seconds without a crash**, and **reconnect Wi-Fi within 3 minutes** if one is configured. 4. **Confirmed:** it marks itself valid and a Toast says `Updated`. If it crashes or restarts first, the bootloader marks the image *aborted* and boots the **previous firmware** again, which says the update failed. Meanwhile that previous firmware stayed in its slot: it is the way back. **Two lines of defence.** The bootloader's rollback is the first. The firmware counts its own boots on Probation, very first thing in `setup()`, and reverts itself on the second unconfirmed start, as a second line. And Arduino-ESP32 normally marks an image valid *before* `setup()` runs, which once hid the bootloader's rollback entirely; the firmware overrides `verifyRollbackLater()` so an image stays pending until Probation confirms it. ## Who signs releases A tag `v*` is built, signed and published by Gitea Actions, with the signing key held as a repository secret as well as on the maintainer's machine ([ADR 0008](/dev/decisions/0008-ci-signs-releases/) says what that costs and what limits it). The release step checks the signed file against the public key in the sources it built, so a wrong secret stops the release instead of publishing a file no device accepts. **If the private key is ever lost,** the next update has to go over USB, carrying a new public key. Someone with USB access can always flash anything: only Wi-Fi and SD card updates are guarded, by design (ADR 0003).