Signed firmware¶
With security_mode: "signed", every firmware binary is signed with an
Ed25519 private key that only you hold. SimpleOTA verifies the signature
at upload time and delivers it to devices at check time; your devices
verify it again before flashing. Even if your account or a CI token is
compromised, devices will not flash a binary that was not signed with
your key.
How the trust chain works¶
- You register a public key with your project. The private key never leaves your infrastructure (or, if SimpleOTA generates the pair for you, it is shown exactly once and never stored).
- Your build pipeline signs the raw firmware binary bytes and uploads binary + signature. SimpleOTA verifies the signature against the project's key before accepting the artifact.
- At OTA check time, eligible devices receive the signature alongside the pre-signed download URL.
- The device downloads the binary, verifies the SHA-256 checksum, then verifies the Ed25519 signature against its pinned public key, and only then writes to flash.
Step 4 is what makes the guarantee real. Server-side verification alone cannot protect a device from a compromised delivery path; on-device verification can.
1. Create a signing key¶
Dashboard: Project (Advanced mode) → Firmware signing keys → New key. Leave the public key field blank to have SimpleOTA generate a keypair; the private key is displayed once. Or paste your own Ed25519 public key.
API:
curl -X POST https://simpleota.com/api/v1/projects/$PROJECT_ID/signing-keys/ \
-H "Authorization: Bearer $SIMPLEOTA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"key_id": "prod-2026"}'
The response contains private_key_pem exactly once. Store it in your CI
secret store immediately.
To bring your own key instead, generate it locally and register only the public half:
openssl genpkey -algorithm ed25519 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem
# register public.pem via the dashboard or pass it as "public_key_pem"
2. Sign and upload¶
No CI yet? Sign in your browser¶
The dashboard upload form has a Sign in browser helper: pick your binary, select a signing key from the dropdown, paste your private key PEM, and the signature is computed locally via WebCrypto. The private key field is never part of the form submission, so the key stays on your machine. This is the easiest path while you set up CI.
Trade-off, stated plainly: browser signing means trusting the page's JavaScript at signing time. For commercial fleets, sign in CI where the key lives in a proper secret store.
Sign in CI (recommended for production)¶
The signature covers the raw binary bytes, exactly as uploaded (and exactly as later delivered to devices).
SIGNATURE=$(openssl pkeyutl -sign -inkey private.pem -rawin -in firmware.bin | base64 -w0)
cat > manifest.json <<EOF
{
"framework": "arduino",
"version_label": "2.1.0",
"chip_family": "esp32",
"security_mode": "signed",
"signing_metadata": {
"key_id": "prod-2026",
"algorithm": "ed25519",
"signature": "$SIGNATURE"
}
}
EOF
curl -X POST https://simpleota.com/api/v1/projects/$PROJECT_ID/artifacts/ \
-H "Authorization: Bearer $SIMPLEOTA_TOKEN" \
-F "manifest=<manifest.json" \
-F "[email protected]"
Uploads with a missing or invalid signature, an unknown key_id, or a
revoked key are rejected with a 400 and a specific error message. No
build number is consumed by a rejected upload.
3. OTA check response¶
For a signed artifact, eligible devices receive three extra fields:
{
"update_available": true,
"build_number": 12,
"url": "https://firmware.simpleota.com/...",
"checksum": "8f3c...",
"size": 1287456,
"security_mode": "signed",
"signing_key_id": "prod-2026",
"signature_algorithm": "ed25519",
"signature": "<base64>"
}
4. Verify on the device (required)¶
Pin the public key in your firmware (it is not a secret). Verify after download, before flashing.
Arduino: built into SimpleOTAClient v0.4.0¶
The SimpleOTAClient library does this for you. Pin the key and advertise signed mode; every signed offer is then verified over the exact downloaded bytes, streamed during download, before the image is marked bootable:
ota.setSecurityMode(SimpleOTAClient::SECURITY_MODE_SIGNED);
ota.setSigningPublicKey(
"-----BEGIN PUBLIC KEY-----\n"
"MCowBQYDK2VwAyEA...your project public key...\n"
"-----END PUBLIC KEY-----\n");
A tampered or unapproved image is rejected (apply() returns
OTA_SIGNATURE_FAIL), the device keeps running its current firmware, and
a failed event with reason signature_invalid appears on the dashboard
device row. During key rotation, pin the outgoing and incoming keys side
by side with addSigningPublicKey(keyId, pem) (maximum two). See the
library's examples/SignedOTA sketch.
ESP-IDF and custom integrations: manual verification¶
With mbedTLS (bundled with ESP-IDF):
#include "mbedtls/pk.h"
// PEM (null-terminated) of your project public key, compiled into firmware.
static const char SIGNING_PUBKEY_PEM[] =
"-----BEGIN PUBLIC KEY-----\n"
"...your key...\n"
"-----END PUBLIC KEY-----\n";
// Returns true only if `sig` (raw 64 bytes, base64-decoded from the OTA
// response) is a valid Ed25519 signature over the downloaded image.
bool verify_firmware_signature(const uint8_t *image, size_t image_len,
const uint8_t *sig, size_t sig_len) {
mbedtls_pk_context pk;
mbedtls_pk_init(&pk);
if (mbedtls_pk_parse_public_key(&pk, (const uint8_t *)SIGNING_PUBKEY_PEM,
sizeof(SIGNING_PUBKEY_PEM)) != 0) {
mbedtls_pk_free(&pk);
return false;
}
int rc = mbedtls_pk_verify_ext(MBEDTLS_PK_ED25519, NULL, &pk,
MBEDTLS_MD_NONE, image, image_len,
sig, sig_len);
mbedtls_pk_free(&pk);
return rc == 0;
}
When verification fails, do not mark the image bootable. Report it so the dashboard (and any watching automation) sees the failure for what it is:
signature_invalid is the canonical reason token for this case; see the
failure reasons table.
Verify before flashing, always
If your device flashes without verifying, the signed mode gives you
no more protection than basic. The whole point of the signature is
that the device refuses unapproved images even when everything
upstream of it is compromised.
If your image is too large to buffer in RAM, verify incrementally:
Ed25519 requires the full message, so either stream the image to the
inactive OTA partition first and verify by reading it back before
marking it bootable, or use esp_secure_boot for hardware-backed
chains.
Note that mbedTLS Ed25519 support requires mbedTLS 3.6+ (ESP-IDF 5.3+).
For older toolchains, compact standalone implementations such as
ed25519-donna or c25519 are widely used on ESP32-class hardware.
Migrating a fleet from basic to signed¶
The server only offers an artifact to a device whose reported
security_mode matches the artifact's (exactly, or when either side is
blank); see how modes are matched.
Keys are compiled into the firmware, not stored on the device. Both facts
shape the migration:
- Ship one final
basicupload whose firmware pins the public key (setSigningPublicKey(...)) and switches the reported mode (setSecurityMode(SIGNED)). Abasicartifact reaches devices reportingbasicand devices that never reported a mode, so the whole fleet gets it regardless of history. - Devices running the new firmware report
security_mode: "signed"and verify every subsequent update on-device. - From then on, upload only signed artifacts. The matching rule is
symmetric: a
signeddevice is never offered abasicartifact, so there is no quiet fallback to unsigned releases.
Do not try to migrate by uploading the new firmware as a signed
artifact: devices explicitly reporting basic will never be offered it,
and devices that would accept it cannot verify it yet anyway. The
device's mode is changed by the firmware it runs, not by the mode of the
artifact that delivered it.
Watch for stragglers
A device blocked by mode mismatch does not error; it receives a
normal no_compatible_build response and silently stays on its
current build. After migrating, check the project device list for
devices still reporting basic (stuck on an old build) before you
stop publishing the migration artifact.
Key rotation and revocation¶
- A project can hold multiple keys. Each artifact records which
key_idsigned it, shown as a badge in the dashboard. - Revoking a key (dashboard or
DELETE /api/v1/projects/{id}/signing-keys/{key_id}/) immediately blocks new uploads signed with it. Artifacts already verified with it remain servable, so in-flight rollouts are not disrupted. - To rotate: create
key-2027, update your CI secret and device firmware (pin both keys during the transition), then revokekey-2026.
Threat model, honestly stated¶
Signing protects devices from flashing binaries that you did not approve: a compromised SimpleOTA account, a leaked upload token, or a tampered storage object all fail device-side verification. It does not protect against a compromised build pipeline that holds your private key, and it is not a substitute for keeping upload credentials out of device firmware. Keep the private key in a secret store with the narrowest possible access.
This is not Secure Boot¶
Verification runs inside the application, so the trust anchor is the firmware the device is already running. That protects the OTA delivery path; it does not protect the device itself:
- Physical access defeats it. Every ESP32 ships with a ROM download
mode: an attacker with the device in hand can reflash arbitrary
firmware over UART/USB with
esptool. The OTA-level check never runs. - Already-compromised firmware defeats it. Code that is running does not verify itself.
The hardware answer to those threats is ESP32 Secure Boot (v2): an eFuse-burned key makes the ROM verify the bootloader and the bootloader verify the application on every boot, so physically reflashed unsigned images will not boot (add Flash Encryption to also prevent firmware readout). It is irreversible and adds toolchain complexity, which is why it is a deliberate opt-in.
The two compose cleanly and answer different questions: SimpleOTA signing
protects the supply chain to the device; Secure Boot protects the
device itself. Fleets facing physical attackers should run both.
SimpleOTA signing still adds value alongside Secure Boot: it rejects a
bad image before the reboot/rollback cycle and reports
signature_invalid to the dashboard, giving you a fleet-level tamper
signal that silent bootloader rejection does not.
Using SimpleOTA with Secure Boot enabled¶
No SimpleOTA configuration is needed: Secure Boot v2 signatures are
appended to the .bin itself, and SimpleOTA stores, checksums, signs,
and delivers those exact bytes untouched. Two things to get right:
- Signing order in CI. The SimpleOTA Ed25519 signature must cover
the final, shippable bytes:
build -> secure-boot sign (espsecure) -> SimpleOTA sign -> upload. Signing or uploading before the secure-boot step changes the bytes afterwards and the upload is rejected (checksum mismatch). - Failure semantics. An image that fails Secure Boot verification is
rejected by
esp_ota_set_boot_partition(); on Arduino this surfaces as afailedevent with reasonupdate_end_failed(notsignature_invalid, which is reserved for the SimpleOTA Ed25519 check).
If you also enable ESP-IDF anti-rollback (eFuse secure_version),
prefer the bootloader's own rollback over the Arduino library's
trial/rollback for transitions that bump the counter: an older image
below the eFuse version will not boot. Secure Boot is practical mainly
on ESP-IDF (Arduino cores ship precompiled bootloaders without it); the
ESP-IDF integration guide covers it in more depth as that client
matures.
Related¶
- Security modes
- Manifest schema
- Device tokens: combine with signing for per-device auth