Files
roro9stack/site/content/dev/build/how-an-update-works/index.md
T
twislaandClaude Opus 5.5 c68741cc46
CI / build (pull_request) Successful in 7m20s
Site / build (pull_request) Successful in 9s
One firmware: the Debug Console in every build, off until switched on, with the device's own token
There is no Debug Build any more (ADR 0010, issue #68, Q188 to Q195). The
console and the test commands are compiled into every firmware. It listens
only while Settings > Debug Console is on, which isn't the default; off,
neither its task nor its 4 KB ring exists. The token is made by the device
and shown on that page; a client proves it knows it by answering a challenge
with an HMAC, so it never crosses the network, and five wrong answers close
the console for a minute. DBG in the Status Bar while it listens.

Over USB serial only: debug on, debug token <value>, debug token new.
scripts/flash.sh --debug uses them to set a device up with the developer's
token. scripts/rdbg.py takes the token from -t, $RORO_DEBUG_TOKEN or the
file, answers the challenge, and fetches a release's ELF to decode a crash.

Gone: the cardputer-adv-debug environment, RORO_DEBUG, the +debug version,
scripts/debug_flags.py, update install ... force, and the rule that a Debug
Build doesn't install releases. Old clients and old firmwares don't talk to
each other.

Against the builds it replaces: 30 KB more flash and 88 bytes more static
RAM than the release, 4 KB less RAM than the Debug Build. 468 host tests.
Checked on the device: off by default, login, the pause after wrong tokens,
Safe Mode with the console, the setting surviving an update, debug off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 22:59:35 +02:00

5.3 KiB

+++ 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 (the signature), ADR 0005 (when the new firmware crashes) and ADR 0008 (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:

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

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 <path> 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 <tag>): see Flash and update.

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 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).