Security & Deployment

OTA Firmware Updates

Implement over-the-air firmware updates for ESP32 and Raspberry Pi — version management, signed updates, rollback mechanisms, and staged fleet rollouts.

OTA Firmware Updates

Firmware vulnerabilities discovered after deployment can only be fixed through OTA (Over-The-Air) updates. Planning OTA from day one is not optional — it is the only way to maintain a secure, functional IoT fleet without physically touching every device.


Why OTA Matters

Without OTA:
  Device deployed → Vulnerability discovered → No fix possible
  → Every device in the field is permanently vulnerable
  → Only fix: physical recall (expensive, often impossible)

With OTA:
  Device deployed → Vulnerability discovered → Patch developed
  → Staged rollout to fleet → All devices patched in hours
  → Security maintained throughout device lifecycle

ESP32 OTA — Arduino Framework

The Arduino framework provides built-in OTA via the Update class and the ArduinoOTA library.

Basic HTTP OTA

cpp
// Arduino ESP32 — Pull-based OTA (device checks for updates)
#include <WiFi.h>
#include <HTTPClient.h>
#include <Update.h>
#include <ArduinoJson.h>

const char* FIRMWARE_SERVER = "https://firmware.bigxstar.com";
const char* DEVICE_ID       = "esp32-001";
const char* CURRENT_VERSION = "1.4.2";  // Hardcoded at compile time

struct FirmwareInfo {
    String version;
    String url;
    String sha256;
    bool available = false;
};

FirmwareInfo checkForUpdate() {
    FirmwareInfo info;
    HTTPClient http;
    String url = String(FIRMWARE_SERVER) + "/manifest.json?device=" + DEVICE_ID;

    http.begin(url);
    int code = http.GET();

    if (code == 200) {
        StaticJsonDocument<512> doc;
        deserializeJson(doc, http.getString());

        String latest = doc["version"].as<String>();
        if (latest != CURRENT_VERSION) {
            info.version   = latest;
            info.url       = doc["url"].as<String>();
            info.sha256    = doc["sha256"].as<String>();
            info.available = true;
        }
    }
    http.end();
    return info;
}

bool performUpdate(String firmwareUrl) {
    HTTPClient http;
    http.begin(firmwareUrl);
    http.addHeader("X-Device-ID", DEVICE_ID);
    http.addHeader("X-Current-Version", CURRENT_VERSION);

    int code = http.GET();
    if (code != 200) {
        Serial.printf("Firmware fetch failed: %d\n", code);
        http.end();
        return false;
    }

    int contentLength = http.getSize();
    WiFiClient* stream = http.getStreamPtr();

    Serial.printf("Updating: %d bytes\n", contentLength);

    if (!Update.begin(contentLength)) {
        Serial.printf("Update.begin failed: %s\n", Update.errorString());
        return false;
    }

    size_t written = Update.writeStream(*stream);
    http.end();

    if (written != contentLength) {
        Serial.printf("Written %d/%d bytes\n", written, contentLength);
        Update.abort();
        return false;
    }

    if (!Update.end()) {
        Serial.printf("Update.end failed: %s\n", Update.errorString());
        return false;
    }

    Serial.println("Update complete — restarting");
    delay(1000);
    ESP.restart();
    return true;  // Never reached
}

void setup() {
    Serial.begin(115200);
    WiFi.begin("MySSID", "MyPassword");
    while (WiFi.status() != WL_CONNECTED) delay(500);

    Serial.printf("Firmware v%s running\n", CURRENT_VERSION);

    FirmwareInfo update = checkForUpdate();
    if (update.available) {
        Serial.printf("Update available: v%s\n", update.version.c_str());
        performUpdate(update.url);
    } else {
        Serial.println("Firmware is up to date");
    }
}

void loop() {
    // Check for updates every 6 hours
    static unsigned long lastCheck = 0;
    if (millis() - lastCheck > 6UL * 3600 * 1000) {
        lastCheck = millis();
        FirmwareInfo update = checkForUpdate();
        if (update.available) performUpdate(update.url);
    }
    // ... main application code
}

MicroPython OTA

python
# ota.py — MicroPython OTA updater
import urequests
import uhashlib
import ubinascii
import os
import machine

FIRMWARE_SERVER  = "https://firmware.bigxstar.com"
CURRENT_VERSION  = "1.4.2"
DEVICE_ID        = "esp32-node-001"

def check_update():
    """Check if a newer firmware is available."""
    url = f"{FIRMWARE_SERVER}/manifest.json"
    try:
        r = urequests.get(url, timeout=10)
        manifest = r.json()
        r.close()
        latest = manifest.get("version")
        if latest and latest != CURRENT_VERSION:
            return manifest
    except Exception as e:
        print(f"Update check failed: {e}")
    return None

def download_file(url, dest_path, expected_sha256=None):
    """Download a file and optionally verify its SHA256 hash."""
    print(f"Downloading {url} → {dest_path}")
    tmp_path = dest_path + ".tmp"

    try:
        r = urequests.get(url, timeout=30)
        if r.status_code != 200:
            print(f"Download failed: HTTP {r.status_code}")
            return False

        # Stream to file
        hasher = uhashlib.sha256()
        with open(tmp_path, 'wb') as f:
            chunk_size = 4096
            data = r.content  # MicroPython: read all at once (memory permitting)
            f.write(data)
            hasher.update(data)
        r.close()

        # Verify hash
        if expected_sha256:
            actual_hash = ubinascii.hexlify(hasher.digest()).decode()
            if actual_hash.lower() != expected_sha256.lower():
                print(f"Hash mismatch! Expected {expected_sha256}, got {actual_hash}")
                os.remove(tmp_path)
                return False
            print(f"Hash verified: {actual_hash}")

        # Atomic replace
        try:
            os.remove(dest_path)
        except:
            pass
        os.rename(tmp_path, dest_path)
        return True

    except Exception as e:
        print(f"Download error: {e}")
        try: os.remove(tmp_path)
        except: pass
        return False

def apply_update(manifest):
    """Download and apply new firmware files."""
    print(f"Applying update to v{manifest['version']}")

    files = manifest.get("files", [])
    downloaded = []

    for file_info in files:
        url  = FIRMWARE_SERVER + "/" + file_info["path"]
        dest = "/" + file_info["path"]
        sha  = file_info.get("sha256")

        if download_file(url, dest, sha):
            downloaded.append(dest)
            print(f"Updated: {dest}")
        else:
            print(f"FAILED: {dest}")
            # Cleanup downloaded files
            for f in downloaded:
                try: os.remove(f)
                except: pass
            return False

    # Write new version marker
    with open('/version.txt', 'w') as f:
        f.write(manifest['version'])

    print(f"Update to v{manifest['version']} complete. Rebooting...")
    machine.reset()

def check_and_update():
    """Main OTA entry point — call from boot.py."""
    manifest = check_update()
    if manifest:
        print(f"Update available: v{manifest['version']}")
        apply_update(manifest)
    else:
        print(f"Firmware v{CURRENT_VERSION} is current")

Firmware Manifest Server (Python / FastAPI)

python
# firmware_server.py — serve firmware manifests and binaries
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import FileResponse
import hashlib, os, json
from pathlib import Path

app = FastAPI()

FIRMWARE_DIR = Path("./firmware")
FIRMWARE_DIR.mkdir(exist_ok=True)

def sha256_file(path):
    h = hashlib.sha256()
    with open(path, 'rb') as f:
        while chunk := f.read(8192):
            h.update(chunk)
    return h.hexdigest()

@app.get("/manifest.json")
async def get_manifest(
    x_device_id: str = Header(None),
    x_current_version: str = Header(None)
):
    """Return the latest firmware manifest."""
    latest_version = "1.5.0"
    firmware_file  = FIRMWARE_DIR / f"esp32_firmware_{latest_version}.bin"

    if not firmware_file.exists():
        return {"version": latest_version, "up_to_date": True}

    if x_current_version == latest_version:
        return {"version": latest_version, "up_to_date": True}

    return {
        "version": latest_version,
        "url":     f"/firmware/esp32_firmware_{latest_version}.bin",
        "sha256":  sha256_file(firmware_file),
        "size":    firmware_file.stat().st_size,
        "mandatory": False,          # Set True to force update
        "changelog": "Bug fixes and security patches"
    }

@app.get("/firmware/{filename}")
async def download_firmware(filename: str, x_device_id: str = Header(None)):
    """Serve firmware binary with logging."""
    path = FIRMWARE_DIR / filename
    if not path.exists() or not path.is_relative_to(FIRMWARE_DIR):
        raise HTTPException(404)

    print(f"Firmware download: {filename} by device={x_device_id}")
    return FileResponse(path, media_type="application/octet-stream")

Staged Rollout

python
# rollout_manager.py — gradual fleet update rollout
import json
import random
from datetime import datetime

class RolloutManager:
    """
    Staged firmware rollout with automatic rollback on error rate.
    Rollout stages: 1% → 10% → 50% → 100%
    """
    def __init__(self):
        self.stages = [
            {"percentage": 1,   "min_hours": 1,  "max_error_rate": 0.10},
            {"percentage": 10,  "min_hours": 4,  "max_error_rate": 0.05},
            {"percentage": 50,  "min_hours": 12, "max_error_rate": 0.02},
            {"percentage": 100, "min_hours": 24, "max_error_rate": 0.01},
        ]
        self.current_stage = 0
        self.deployed    = {}  # device_id → version
        self.errors      = {}  # version → error count

    def should_update(self, device_id, device_type="default"):
        """Decide whether this device should receive the update now."""
        stage = self.stages[self.current_stage]
        # Deterministic but spread: hash device_id to get a percentage
        bucket = int(hashlib.md5(device_id.encode()).hexdigest(), 16) % 100
        return bucket < stage["percentage"]

    def record_update_result(self, device_id, version, success):
        if version not in self.errors:
            self.errors[version] = {"total": 0, "failures": 0}
        self.errors[version]["total"] += 1
        if not success:
            self.errors[version]["failures"] += 1

        error_rate = self.errors[version]["failures"] / self.errors[version]["total"]
        stage = self.stages[self.current_stage]

        if error_rate > stage["max_error_rate"] and self.errors[version]["total"] >= 10:
            print(f"ROLLBACK! Error rate {error_rate:.1%} exceeds threshold "
                  f"{stage['max_error_rate']:.0%}")
            self.current_stage = max(0, self.current_stage - 1)
        else:
            print(f"Update v{version}: {self.errors[version]['total']} devices, "
                  f"{error_rate:.1%} error rate")

import hashlib

mgr = RolloutManager()
devices = [f"esp32-{i:04d}" for i in range(1000)]

for device in devices:
    if mgr.should_update(device):
        success = random.random() > 0.02  # 2% failure rate
        mgr.record_update_result(device, "1.5.0", success)

Raspberry Pi OTA (systemd)

bash
#!/bin/bash
# update.sh — deployed on Raspberry Pi via cron or systemd timer

CURRENT=$(cat /etc/app/version.txt)
MANIFEST=$(curl -sf https://firmware.bigxstar.com/pi/manifest.json)
LATEST=$(echo "$MANIFEST" | python3 -c "import json,sys; print(json.load(sys.stdin)['version'])")

if [ "$CURRENT" = "$LATEST" ]; then
    echo "Already on v$LATEST"
    exit 0
fi

echo "Updating from v$CURRENT to v$LATEST"
URL=$(echo "$MANIFEST" | python3 -c "import json,sys; print(json.load(sys.stdin)['url'])")

# Download to temp
curl -sf "$URL" -o /tmp/app_update.tar.gz

# Verify hash
EXPECTED=$(echo "$MANIFEST" | python3 -c "import json,sys; print(json.load(sys.stdin)['sha256'])")
ACTUAL=$(sha256sum /tmp/app_update.tar.gz | cut -d' ' -f1)

if [ "$EXPECTED" != "$ACTUAL" ]; then
    echo "Hash mismatch! Aborting update."
    exit 1
fi

# Stop app, update, restart
sudo systemctl stop iot-app

tar -xzf /tmp/app_update.tar.gz -C /opt/iot-app

echo "$LATEST" > /etc/app/version.txt
sudo systemctl start iot-app

echo "Updated to v$LATEST successfully"

Summary

  • OTA updates are non-negotiable for production IoT — plan the update mechanism before first deployment
  • Always verify firmware integrity with SHA256 before applying any update
  • Use atomic updates (write to temp file, then rename) to prevent corruption on power loss mid-download
  • Staged rollouts (1% → 10% → 50% → 100%) catch regressions before they affect the whole fleet
  • Set an automatic rollback threshold — if error rates exceed N% during rollout, revert the staged percentage
  • Raspberry Pi updates use standard Linux tooling (apt, systemd, shell scripts); ESP32 uses the Update class or urequests

Next, we'll look at production deployment — scaling your IoT system from 10 devices to 10,000.