SimpleOTA
All posts
4 min read Alexander Wasserman

Signed firmware, verified on the device

SimpleOTAClient v0.4.0 verifies an Ed25519 signature on the ESP32 before the new image is marked bootable, so a tampered build never runs.

security esp32 releases

Over-the-air updates are only as trustworthy as the weakest link between your build server and the device. Checking a signature when firmware is uploaded is a good start, but the guarantee that actually matters is on the device: before the new firmware is marked bootable, does it carry proof that you approved it?

As of SimpleOTAClient v0.4.0, the first-party Arduino client now performs that check on the ESP32 itself. It verifies an Ed25519 signature over the exact bytes and aborts before flashing if anything is off.

How it works end to end

  1. You register a project signing key (Ed25519). If you generate on the platform, the private key is shown once and never stored. SimpleOTA only ever keeps the public key.
  2. In CI you sign the raw firmware binary (the .ino.bin or equivalent app image) and include the signature plus key_id in the upload manifest.
  3. SimpleOTA verifies the signature at upload time against your registered public key. Bad or missing signatures are rejected before a build number is allocated.
  4. When a device is eligible for a signed artifact, the OTA check response includes security_mode: "signed", signing_key_id, signature_algorithm, and the base64 signature.
  5. The client downloads the binary over the pre-signed URL. It feeds every byte into both the existing SHA-256 hasher and an incremental Ed25519 verifier (using a vendored Monocypher 3.1.3). Only if both checks pass does it call Update.end() and mark the partition bootable.
flowchart TD A[CI signs raw binary<br/>Ed25519] --> B[Upload + manifest with sig] B --> C[Server verifies sig<br/>rejects invalid early] C --> D[Device checks in] D --> E{Signed offer<br/>and device enforcing?} E -->|yes| F[Stream download:<br/>SHA-256 + Ed25519 incremental] F --> G{Both pass?} G -->|yes| H[Update.end, mark bootable, reboot] G -->|no| I[Abort, report signature_invalid<br/>stay on current build] E -->|no| J[Legacy path or warning only]

The verification runs before the point of no return. A failed signature never touches the bootable partition. The device reports a failed status event with reason signature_invalid, which shows up inline on the device row in the dashboard and in the deployment timeline.

Enforcing devices and anti-downgrade

A device becomes enforcing when it both advertises security_mode: "signed" and has at least one public key pinned via the API. For those devices:

  • A signed offer with a bad or missing signature is rejected before the download even starts (or during, before commit).
  • An offer that lacks the signature fields is also treated as a signature failure. A compromised server or MITM cannot silently downgrade an enforcing device.

Devices that have not yet pinned a key or reported signed mode fall back to checksum-only verification (with a warning in the client logs when a signed artifact is offered). This keeps migration safe.

Migrating an existing fleet

Keys are baked into the firmware at compile time. The server’s compatibility rule for security_mode (exact match or blank on either side) plus that fact gives us a clean migration path with no risk of bricking devices that cannot yet verify:

  1. Build one last artifact with security_mode: "basic". Inside that firmware, call setSigningPublicKey(...) and setSecurityMode(SimpleOTAClient::SECURITY_MODE_SIGNED).
  2. Roll it out. Devices running the new code now report signed mode and pin the key.
  3. From that point forward, publish only signed artifacts. A device reporting signed will never be offered a basic artifact, and vice versa.

A basic device is never offered a signed build it cannot verify. A signed device is never offered an unsigned build. Devices that miss the migration step simply stay put and show up as still reporting basic; you can see them in the device list.

Rotation is supported the same way: pin both the old and new key with addSigningPublicKey(keyId, pem) (up to two), start signing with the new key in CI, then revoke the old key on the server once the fleet has moved.

Using it

In your sketch (before begin() or check()):

ota.setSecurityMode(SimpleOTAClient::SECURITY_MODE_SIGNED);
ota.setSigningPublicKey(
    "-----BEGIN PUBLIC KEY-----\n"
    "MCowBQYDK2VwAyEA...paste the PEM from the dashboard...\n"
    "-----END PUBLIC KEY-----\n"
);

For rotation windows use addSigningPublicKey("key-2026", pem2) as well.

Create keys in Project settings (Advanced mode) or via the developer API. The dashboard upload form includes a “Sign it for me in my browser” helper that computes the signature locally with WebCrypto so the private key never leaves your machine. For production CI, sign with openssl and include the fields in the manifest:

SIGNATURE=$(openssl pkeyutl -sign -inkey private.pem -rawin -in firmware.bin | base64 -A)

Then put security_mode: "signed" and the signing_metadata object in your manifest before the POST to /api/v1/projects/.../artifacts/.

The GitHub Actions and GitLab CI examples in the docs include the signing step.

Full walkthrough, manifest examples, threat model notes (this is not Secure Boot), and Secure Boot composition guidance live in the signed firmware guide.

Defense in depth, and the layer that counts

Server-side verification is defense in depth. Device-side verification is what actually stops a tampered binary from running, even if your account, a CI token, or the storage bucket is compromised. v0.4.0 makes that the default path for Arduino users who opt into signed mode.

If you have been hand-rolling verification from the earlier wiki snippets, you can now delete that code and use the built-in path. The contract is the same: the signature covers the exact bytes delivered by the pre-signed URL.