Hoe pas je OT-veilig OTA updates toe op STM32 en ESP32?
Zo bouw je een OTA-systeem dat firmware veilig en betrouwbaar naar devices in het veld brengt, met werkende code en concrete architectuurbeslissingen.
Praktische handleiding voor veilige firmware updates in dual-microcontroller IoT-systemen.
Waarom OTA updates in OT-omgevingen anders zijn
Stel je voor: je hebt honderden sensornodes draaien in een fabriek. Verspreid over drie verdiepingen, achter machines, in kabelgoten. Fysiek langsgaan voor een firmware update is geen optie. Dus implementeer je OTA (Over-the-Air) updates.
Maar hier zit het probleem. In een reguliere IT-omgeving (denk aan een telefoon of laptop) is een mislukte update vervelend maar zelden fataal. Je herstart, probeert opnieuw, of draait terug via het besturingssysteem. In een OT-omgeving (Operational Technology, denk aan fabrieken, utiliteiten, medische apparatuur) ligt dat fundamenteel anders:
- Geen fysieke toegang. Devices zitten achter panelen, in kabelgoten, op daken. Een gebrickt device betekent een servicetechnicus sturen.
- Geen vangnet. Embedded microcontrollers hebben geen besturingssysteem met een ingebouwd recovery-menu. Als de firmware corrupt is, start het device simpelweg niet meer op.
- Het netwerk is vijandig. In OT-omgevingen moet je ervan uitgaan dat het netwerk niet te vertrouwen is. Een aanvaller die een firmware update onderschept en vervangt door kwaadaardige code kan in het ergste geval duizenden devices overnemen of onbruikbaar maken.
Het risico van onveilige OTA updates is daarmee niet alleen technisch maar ook financieel en operationeel. Een gecompromitteerde update kan productieprocessen stilleggen, gevoelige meetdata lekken, of apparatuur permanent beschadigen. De vraag is niet *of* je OTA nodig hebt, maar *hoe* je het veilig implementeert.
Dit artikel laat stap voor stap zien hoe je een veilige OTA-pipeline opzet voor een dual-microcontroller platform (ESP32-S3 + STM32F767). De aanpak is toepasbaar op vergelijkbare embedded systemen. Alle codevoorbeelden komen uit een werkend systeem.
De architectuur in vogelvlucht
Het platform bestaat uit twee microcontrollers die samenwerken. De ESP32-S3 is de gateway: hij heeft WiFi, verbindt met de cloud, en beheert alle communicatie. De STM32F767 doet de real-time sensoracquisitie: temperatuur, stroom, spanning. Beide moeten in het veld updatable zijn.
De ESP32 neemt de rol van OTA-coördinator op zich. Hij verbindt met AWS IoT Core over een beveiligde MQTT-verbinding, ontvangt update-notificaties, downloadt firmware over HTTPS, en flasht zichzelf óf streamt het binair naar de STM32 via UART. De STM32 heeft geen eigen netwerkverbinding en is volledig afhankelijk van de ESP32 als proxy.
Bij het ontwerp van dit systeem waren er drie uitgangspunten die elke technische keuze bepaalden:
- De firmware past niet in het werkgeheugen. De ESP32 heeft ongeveer 300KB vrij RAM. Een firmware image kan 1 tot 2MB zijn. Daarom wordt alles gestreamd: de firmware wordt in kleine blokken gedownload, verwerkt en doorgestuurd. Het volledige binair bestaat nooit in één keer in het geheugen.
- Verifieer voor je wist. Op beide platformen wordt de nieuwe firmware volledig gecontroleerd voordat de oude wordt overschreven. Dit is het verschil tussen een gefaalde update en een onbruikbaar device.
- Meerdere onafhankelijke verificatielagen. TLS voor transport, RSA-handtekeningen voor authenticiteit, SHA256 voor hash-verificatie, CRC32 voor data-integriteit. Als één laag faalt, vangen de andere het op.
De update pushen naar de cloud
Voordat een device een update kan ontvangen, moet de firmware eerst gebouwd, gesigneerd, en beschikbaar gemaakt worden. Dit gebeurt op de build server (in dit geval GitHub Actions, maar het principe geldt voor elke CI/CD-omgeving).
De stappen zijn:
- Bouwen. De firmware wordt gecompileerd voor beide platformen (ESP-IDF voor de ESP32, ARM GCC voor de STM32).
- Signeren. Elke firmware binary wordt cryptografisch gesigneerd met een RSA private key. Deze sleutel bestaat alleen op de build server en wordt nooit naar een device gekopieerd. Dit is de kern van het beveiligingsmodel: alleen de build server kan geldige firmware produceren.
- Uploaden. De gesigneerde firmware wordt geüpload naar AWS S3. Er wordt een tijdgelimiteerde download-URL gegenereerd (typisch 15 tot 30 minuten geldig).
- Notificatie versturen. Via MQTT wordt een JSON-bericht gepubliceerd naar het topic
gateway/ota/notify. Dit bericht bevat de download-URL, de digitale handtekening, de verwachte CRC32, en het doelplatform.
Het resultaat is een MQTT-notificatie die er zo uitziet:
{
"target": "stm32",
"version": "2.1.0",
"url": "https://s3.eu-west-1.amazonaws.com/firmware/stm32_v2.1.0.bin",
"size": 245760,
"sha256": "a1b2c3d4...",
"signature_rsa": "base64-encoded-rsa-2048-signature==",
"crc32": 305419896,
"auto_apply": true
}
Let op de scheiding: de metadata (URL, handtekening, checksums) reist over het beveiligde MQTT-kanaal. Het binair zelf reist apart over HTTPS. Een aanvaller zou beide kanalen tegelijk moeten compromitteren om een kwaadaardige update door te voeren.
De update ontvangen van de cloud
De ESP32 houdt een persistente MQTT-verbinding open met AWS IoT Core. Deze verbinding is beveiligd met mutual TLS (mTLS): niet alleen verifieert het device de identiteit van de broker, maar de broker verifieert ook de identiteit van het device via een X.509-certificaat. Alleen geregistreerde devices kunnen berichten ontvangen.
Wanneer een notificatie binnenkomt, parseert de OTA Manager het JSON-bericht en routeert op basis van het target veld:
static void on_mqtt_ota_notify(event_type_t type, void *data)
{
const char *json_payload = (const char *)data;
cJSON *root = cJSON_Parse(json_payload);
cJSON *target_obj = cJSON_GetObjectItem(root, "target");
const char *target = cJSON_IsString(target_obj) ? target_obj->valuestring : "esp32";
if (strcmp(target, "esp32") == 0) {
// Haal url, version, size, auto_reboot op en start ESP32 OTA
} else if (strcmp(target, "stm32") == 0) {
// Haal url, version, size, signature_rsa, crc32 op en start STM32 OTA
}
}
Tijdens het hele updateproces rapporteert de OTA Manager de status terug naar de cloud:
EVENT_OTA_STARTED → {"target":"stm32","status":"started","message":"Downloading firmware"}
EVENT_OTA_PROGRESS → {"target":"stm32","progress":45}
EVENT_OTA_COMPLETED → {"target":"stm32","status":"completed","message":"Firmware verified"}
Hierdoor kun je vanuit een dashboard exact volgen waar elk device in het updateproces zit.
De ESP32 updaten
De ESP32 zelf updaten is het eenvoudigste pad, omdat ESP-IDF hier goede tooling voor biedt. De ESP32 gebruikt een 8MB flash met een drie-slot partitielayout:
Het idee achter dit "ping-pong" schema: als de ESP32 draait vanuit OTA_0, wordt de nieuwe firmware naar OTA_1 geschreven (en omgekeerd). De oude firmware blijft intact totdat de nieuwe zichzelf heeft bewezen. De factory partition is de ultieme fallback: als beide OTA-slots onbruikbaar zijn, start het device daarvandaan op.
De update-flow gebruikt de ESP-IDF esp_https_ota API, die de TLS-handshake, chunked download, flash writes, en image-validatie afhandelt:
// Configureer HTTPS client met TLS-certificaatbundel
esp_http_client_config_t http_config = {
.url = notification->url,
.crt_bundle_attach = esp_crt_bundle_attach, // Ingebouwde CA-bundel
.timeout_ms = 30000,
.buffer_size = 4096,
};
esp_https_ota_handle_t ota_handle = NULL;
esp_https_ota_begin(&ota_config, &ota_handle);
// Streaming download: elke aanroep leest een chunk en schrijft naar flash
while (esp_https_ota_perform(ota_handle) == ESP_ERR_HTTPS_OTA_IN_PROGRESS) {
int read = esp_https_ota_get_image_len_read(ota_handle);
int total = esp_https_ota_get_image_size(ota_handle);
uint8_t progress = (total > 0) ? (read * 100 / total) : 0;
event_bus_publish(EVENT_OTA_PROGRESS, &progress);
}
// Valideer het complete image en schakel de boot-partitie om
esp_https_ota_finish(ota_handle);
Wat hier intern gebeurt:
esp_https_ota_begin()vindt de inactieve OTA-partitie (draai je vanuit ota_0, dan selecteert hij ota_1).esp_https_ota_perform()downloadt de firmware in chunks en schrijft elke chunk direct naar flash. Er is geen tussentijdse buffer voor het volledige image.esp_https_ota_finish()valideert het image-formaat, zet de OTA Data partitie om naar het nieuwe slot, en markeert het image als "wacht op bevestiging".
Rollback-bescherming
Na de reboot moet de applicatie bevestigen dat alles werkt:
void cont_ota_validate_after_boot(void)
{
const esp_partition_t *running = esp_ota_get_running_partition();
bool is_ota = (running->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_0 ||
running->subtype == ESP_PARTITION_SUBTYPE_APP_OTA_1);
if (is_ota) {
esp_ota_mark_app_valid_cancel_rollback();
report_ota_status("esp32", "validated", "New firmware running successfully");
}
}
Als de nieuwe firmware crasht voordat esp_ota_mark_app_valid_cancel_rollback() wordt aangeroepen, rolt de ESP-IDF bootloader automatisch terug naar het vorige OTA-slot. Dat is de kracht van het ping-pong model: je oude firmware is je vangnet.
De STM32 updaten
De STM32 updaten is complexer. Hij heeft geen eigen netwerkverbinding, dus de ESP32 fungeert als proxy: downloaden van de cloud, hashes berekenen, handtekeningen verifiëren, en chunks doorsturen via UART. Het proces heeft vier fases.
Het UART-protocol
Voordat we de fases doorlopen, is het nuttig om het communicatieprotocol te begrijpen. De ESP32 en STM32 communiceren via een custom binair protocol over UART (115200 baud, 8N1). Het protocol is gedefinieerd in een gedeelde header (protocol_common.h) die beide platformen includeren.
Elk packet heeft het onderstaande formaat. De afbeelding toont zowel de generieke framestructuur als de specifieke OTA-commando's die hierover verstuurd worden:
Het protocol werkt als request/response: de ESP32 stuurt een commando en blokkeert tot de STM32 antwoordt of de timeout van 5 seconden verloopt. Er is geen apart ACK/NACK-frame, de STM32 antwoordt met RESP_OK in het STATUS-veld, of met een foutcode. Dat is effectief de bevestiging.
Wat gebeurt er als een chunk niet bevestigd wordt? De ESP32 probeert het maximaal drie keer opnieuw. Als alle drie de pogingen falen (timeout of error response), stopt de gehele download. De ESP32 stuurt dan CMD_FW_UPDATE_ABORT naar de STM32, zodat die zijn Bank 2 staging-area opruimt. Vervolgens publiceert de ESP32 een EVENT_STM32_OTA_FAILED event, waarmee de cloud wordt geïnformeerd. Het systeem keert terug naar idle en is klaar voor een nieuwe poging.
Fase 1: Initiatie en erase
De ESP32 stuurt de firmware-metadata naar de STM32 via CMD_FW_UPDATE_START. De STM32 begint met het wissen van Bank 2, een 1MB flash-erase die tot 7 seconden duurt. De ESP32 pollt elke seconde de status totdat de STM32 klaar is om data te ontvangen:
static bool stm32_start_and_wait_erase(void)
{
cmd_fw_update_start_t start_cmd = {
.total_size = s_ctx.current_update.size,
.crc32 = s_ctx.current_update.crc32,
.chunk_size = FW_CHUNK_DATA_SIZE, // 252 bytes
.version_major = ver_major,
.version_minor = ver_minor,
.version_patch = ver_patch,
};
stm32_protocol_send_command(CMD_FW_UPDATE_START, &start_cmd, sizeof(start_cmd), ...);
// Poll totdat de STM32 klaar is met wissen (max 15 seconden)
while ((os_get_time_ms() - erase_start) < FW_ERASE_TIMEOUT_MS) {
os_delay_ms(1000);
resp_fw_update_status_t fw_status = {0};
stm32_protocol_send_command(CMD_FW_UPDATE_STATUS, NULL, 0, &fw_status, ...);
if (fw_status.state == FW_UPDATE_RECEIVING) return true;
if (fw_status.state == FW_UPDATE_ERROR) return false;
}
return false; // Timeout
}
Fase 2: Streaming
Dit is het hart van het geheugenefficiënte ontwerp. De ESP32 opent een HTTPS-verbinding en verwerkt de response body via een streaming callback. Elke HTTP-chunk (tot 4KB) wordt tegelijkertijd door twee processen verwerkt: een incrementele SHA256-hash (voor latere verificatie), en opsplitsing in 252-byte UART-packets voor de STM32.
static bool stream_to_stm32_cb(const uint8_t *data, uint32_t len, void *user_data)
{
stream_ctx_t *ctx = (stream_ctx_t*)user_data;
// Update SHA256 hash incrementeel
// De volledige binary bestaat nooit in het geheugen
mbedtls_md_update(&ctx->sha256_ctx, data, len);
// Splits de HTTP-chunk op in UART-blokken van 252 bytes
uint32_t offset = 0;
while (offset < len) {
uint16_t space = FW_CHUNK_DATA_SIZE - ctx->partial_len;
uint32_t to_copy = (len - offset < space) ? (len - offset) : space;
memcpy(ctx->partial + ctx->partial_len, data + offset, to_copy);
ctx->partial_len += to_copy;
offset += to_copy;
if (ctx->partial_len == FW_CHUNK_DATA_SIZE) {
// Stuur het blok naar de STM32 via UART (met retry-logica)
if (!send_uart_chunk(ctx, ctx->partial, FW_CHUNK_DATA_SIZE))
return false; // Afbreken bij UART-fout
ctx->partial_len = 0;
}
}
return true;
}
Wat hier belangrijk is: de partial buffer is slechts 252 bytes groot. De SHA256-context accumuleert de hash stap voor stap. Er is op geen enkel moment een buffer van de volledige firmware-grootte nodig. Dit maakt het mogelijk om firmware van 1MB+ te verwerken op een chip met 300KB vrij werkgeheugen.
Elke UART-chunk wordt verstuurd met retry-logica (max 3 pogingen). De STM32 bevestigt elke chunk voordat de volgende wordt verstuurd. Aan de STM32-kant valideert de receiver state machine elk blok op sequentiële volgorde, groottebeperkingen, en succesvolle flash-write.
Fase 3: Verificatie
Na het streamen van de complete firmware wordt de SHA256-hash gefinaliseerd en geverifieerd tegen de RSA-2048 handtekening die in de MQTT-notificatie meekwam:
// Finaliseer de SHA256-hash over de volledige firmware
uint8_t sha256_hash[32];
mbedtls_md_finish(&stream_ctx.sha256_ctx, sha256_hash);
// Verifieer: klopt de RSA-handtekening bij deze hash?
sig_verify_status_t sig_status = serv_signature_verify_hash(
sha256_hash, s_ctx.current_update.signature_rsa);
if (sig_status != SIG_VERIFY_OK) {
// Handtekening ongeldig: stuur ABORT naar STM32, gooi alles weg
stm32_protocol_send_command(CMD_FW_UPDATE_ABORT, NULL, 0, NULL, NULL, 2000);
goto cleanup;
}
De serv_signature_verify_hash() functie gebruikt mbedTLS voor de RSA-verificatie:
sig_verify_status_t serv_signature_verify_hash(
const uint8_t *sha256_hash, // 32-byte SHA256 digest
const char *signature_b64) // Base64-gecodeerde RSA-handtekening
{
// Decodeer base64 naar ruwe signature bytes
uint8_t signature[512];
size_t signature_len = 0;
mbedtls_base64_decode(signature, sizeof(signature), &signature_len,
(const unsigned char*)signature_b64, strlen(signature_b64));
// Verifieer: RSA PKCS#1 v1.5 met SHA256
int ret = mbedtls_pk_verify(&s_sig_verify.pk_ctx,
MBEDTLS_MD_SHA256,
sha256_hash, 32,
signature, signature_len);
return (ret == 0) ? SIG_VERIFY_OK : SIG_VERIFY_ERR_INVALID_SIGNATURE;
}
De RSA public key is gecompileerd in de ESP32-firmware. De bijbehorende private key bestaat alleen op de build server. Dit betekent dat zelfs als iemand fysieke toegang heeft tot het device en de volledige flash dumpt, hij geen geldige firmware-handtekeningen kan produceren.
Fase 4: Commit
Alleen als de RSA-verificatie slaagt, stuurt de ESP32 CMD_FW_UPDATE_END. De STM32 valideert dan zelf nog de CRC32 van de ontvangen firmware in Bank 2, zet de update flag in de RTC backup registers, en reset. Daarmee gaat de controle naar de bootloader.
De STM32 bootloader: waar het echte risicobeheer zit
De bootloader is een apart binair (32KB) dat de eerste twee sectors van Bank 1 inneemt. Hij draait vóór de applicatie op elke boot en is bewust minimaal: geen RTOS, geen complexe drivers, alleen STM32 HAL flash-operaties en UART printf voor diagnostiek. De bootloader is schrijfbeveiligd via option bytes — zelfs een volledig gecrashte applicatie kan hem niet overschrijven.
Dual-bank flash layout
De STM32F767 heeft 2MB flash, georganiseerd als twee banken van elk 1MB:
Het idee: nieuwe firmware wordt altijd naar Bank 2 geschreven. Pas als die volledig geverifieerd is, wordt Bank 1 (de actieve bank) overschreven. De bootloader bewaakt dit proces.
Dual-bank mode is niet de standaardconfiguratie van de STM32F767. Het vereist specifieke option bytes:
| Option Byte | Waarde | Betekenis |
|---|---|---|
nDBANK | 0 | Dual-bank mode ingeschakeld (2 × 1MB) |
nDBOOT | 1 | Altijd booten vanaf Bank 1 |
BOOT_ADD0 | 0x2000 | Boot adres = 0x08000000 (Bank 1 start) |
De bootloader controleert en corrigeert deze option bytes automatisch bij de eerste boot. Als ze niet kloppen, programmeert hij de juiste waarden en triggert een reset:
static void bl_ensure_boot_addr_bank1(void)
{
FLASH_OBProgramInitTypeDef ob;
HAL_FLASHEx_OBGetConfig(&ob);
bool needs_update = false;
// Dual-bank mode vereist: nDBANK bit moet 0 zijn
if (ob.USERConfig & FLASH_OPTCR_nDBANK) {
needs_update = true;
}
// BOOT_ADD0 moet naar Bank 1 wijzen (0x08000000 >> 14 = 0x2000)
if (ob.BootAddr0 != BOOT_ADDR_BANK1) {
needs_update = true;
}
if (!needs_update) return;
HAL_FLASH_Unlock();
HAL_FLASH_OB_Unlock();
// ... programmeer correcte waarden ...
HAL_FLASH_OB_Launch(); // Triggert automatisch een system reset
NVIC_SystemReset();
}
Bootloader schrijfbeveiliging
Naast de option bytes beveiligt de bootloader ook zichzelf tegen overschrijven. Sectors 0 en 1 (waar de bootloader code staat) worden write-protected via de WRPSector option bytes. Dit betekent dat zelfs als de applicatie een bug heeft die wild naar flash schrijft, de bootloader intact blijft:
static void bl_protect_bootloader_sectors(void)
{
FLASH_OBProgramInitTypeDef ob;
HAL_FLASHEx_OBGetConfig(&ob);
// Bits 0-1 moeten 0 zijn (0 = protected, 1 = unprotected)
if ((ob.WRPSector & BL_WRP_SECTORS) == 0) {
return; // Al beschermd
}
HAL_FLASH_Unlock();
HAL_FLASH_OB_Unlock();
ob.OptionType = OPTIONBYTE_WRP;
ob.WRPState = OB_WRPSTATE_ENABLE;
ob.WRPSector = BL_WRP_SECTORS; // Sectors 0 en 1
HAL_FLASHEx_OBProgram(&ob);
HAL_FLASH_OB_Lock();
HAL_FLASH_Lock();
HAL_FLASH_OB_Launch(); // Kan een reset triggeren
}
RTC backup registers als mailbox
De bootloader en de applicatie moeten met elkaar communiceren, maar de bootloader draait vóórdat de applicatie start. De oplossing: RTC backup registers. Dit zijn battery-backed SRAM-registers die een reset overleven. Ze zijn simpel te lezen en schrijven, en vereisen geen filesystem of andere complexe opslaglaag.
| Register | Naam | Waarde | Betekenis |
|---|---|---|---|
| BKP0R | Update Flag | 0xDEADBEEF | Er staat een nieuwe firmware klaar in Bank 2 |
| BKP1R | FW Size | (bytes) | Grootte van de nieuwe firmware |
| BKP2R | FW CRC | (CRC32) | Verwachte CRC32 voor verificatie |
| BKP3R | Boot Attempts | (teller) | Opeenvolgende boots zonder bevestiging |
| BKP4R | FW Version | (packed) | Versie: major<<16 | minor<<8 | patch |
| BKP5R | Boot Confirmed | 0xB007C0DE | De applicatie heeft bevestigd dat hij correct draait |
| BKP6R | Update Retries | (teller) | Aantal mislukte update-pogingen (max 3) |
Wanneer de applicatie een update heeft ontvangen en geverifieerd, schrijft hij 0xDEADBEEF naar BKP0R en reset het device. De bootloader leest dit bij de volgende boot en weet: er is werk te doen.
Het kernprincipe: verify before erase
Dit is de belangrijkste ontwerpbeslissing in het hele systeem. De bootloader verifieert de CRC32 van de nieuwe firmware in Bank 2 voordat hij Bank 1 wist. Hier is de volledige update-functie:
static bool bl_apply_update(void)
{
uint32_t fw_size = BKP_FW_SIZE;
uint32_t expected_crc = BKP_FW_CRC;
uint32_t retry_count = BKP_UPDATE_RETRIES;
// Valideer grootte
if (fw_size == 0 || fw_size > BANK1_APP_MAX_SIZE) {
printf("[BL] ERROR: invalid firmware size\n");
return false;
}
// KRITIEK: verifieer staged firmware VOOR het wissen van Bank 1
// Als deze check faalt, blijft Bank 1 (oude firmware) intact
uint32_t staged_crc = crc32_compute((const uint8_t *)BANK2_BASE, fw_size);
if (staged_crc != expected_crc) {
printf("[BL] ERROR: Staged firmware CRC mismatch!\n");
printf("[BL] Bank 1 (oude firmware) NIET gewist, systeem blijft bootable\n");
BKP_UPDATE_FLAG = 0x00000000; // Corrupte staging, niet opnieuw proberen
BKP_UPDATE_RETRIES = 0;
return false;
}
// NU is het veilig: Bank 2 heeft geldige firmware
bool erase_ok = bl_erase_app_sectors();
bool copy_ok = false;
bool verify_ok = false;
if (erase_ok) {
copy_ok = bl_copy_firmware(fw_size);
if (copy_ok) {
// Verifieer CRC van de kopie in Bank 1
verify_ok = bl_verify_crc(fw_size, expected_crc);
}
}
// Bij falen: retry-logica (max 3 pogingen)
if (!erase_ok || !copy_ok || !verify_ok) {
retry_count++;
BKP_UPDATE_RETRIES = retry_count;
if (retry_count >= MAX_UPDATE_RETRIES) {
printf("[BL] Update mislukt na %u pogingen, gestopt\n", retry_count);
BKP_UPDATE_FLAG = 0x00000000;
BKP_UPDATE_RETRIES = 0;
return false;
}
// Update flag NIET wissen, volgende boot probeert opnieuw
return false;
}
// Succes: reset alle metadata
BKP_UPDATE_FLAG = 0x00000000;
BKP_UPDATE_RETRIES = 0;
BKP_BOOT_ATTEMPTS = 0;
BKP_BOOT_CONFIRMED = 0; // Nieuwe firmware moet zichzelf bevestigen
return true;
}
Waarom is dit zo belangrijk? Stel dat de firmware corrupt is geraakt tijdens de UART-transfer (door een storing, stroomuitval, of bug). Zonder de verify-before-erase check zou de bootloader eerst Bank 1 wissen (de werkende firmware vernietigen) en dan pas ontdekken dat Bank 2 onbruikbaar is. Resultaat: een gebrickt device.
Met verify-before-erase controleert de bootloader eerst of Bank 2 geldige firmware bevat. Als dat niet zo is, raakt hij Bank 1 niet aan. De oude firmware blijft intact. Het device start gewoon op met de vorige versie.
Merk ook de retry-logica op: als het kopiëren of de verificatie na het kopiëren faalt (bijvoorbeeld door een flash-schrijffout), probeert de bootloader het bij de volgende boot opnieuw, maximaal drie keer. De staging area in Bank 2 is immers nog intact. Pas na drie mislukte pogingen geeft de bootloader op en wist hij de update-flag.
Vector table validatie
Voordat de bootloader naar de applicatie springt, valideert hij de vector table. De eerste twee woorden van de applicatie in flash zijn de initial stack pointer en de Reset_Handler. De bootloader controleert dat deze naar geldige geheugenregio's wijzen:
static bool bl_validate_app(void)
{
uint32_t app_sp = *(volatile uint32_t *)APP_ADDRESS; // 0x08008000
uint32_t app_pc = *(volatile uint32_t *)(APP_ADDRESS + 4); // 0x08008004
// Stack pointer moet in SRAM liggen (0x20000000 - 0x20080000)
if (app_sp < 0x20000000 || app_sp > 0x20080000) {
printf("[BL] ERROR: Invalid SP (niet in RAM)\n");
return false;
}
// Reset_Handler moet in het applicatie flash-bereik liggen
if (app_pc < APP_ADDRESS || app_pc >= (APP_ADDRESS + BANK1_APP_MAX_SIZE)) {
printf("[BL] ERROR: Invalid Reset_Handler (niet in app flash)\n");
return false;
}
return true;
}
Als de stack pointer niet naar RAM wijst of de program counter niet naar het applicatie-flash-bereik wijst, is er geen geldige applicatie aanwezig. De bootloader stopt en blinkt een error-LED, liever stilstaan dan springen naar ongedefinieerd geheugen.
Boot attempt counter
De applicatie moet na het opstarten bevestigen dat hij correct draait door 0xB007C0DE naar BKP5R te schrijven. De bootloader telt elke boot zonder bevestiging. Als drie opeenvolgende boots mislukken (de applicatie crasht voordat hij zichzelf kan bevestigen), markeert de bootloader de applicatie als potentieel defect:
static bool bl_check_boot_attempts(void)
{
uint32_t attempts = BKP_BOOT_ATTEMPTS;
uint32_t confirmed = BKP_BOOT_CONFIRMED;
// Vorige boot bevestigd? Reset de teller
if (confirmed == BOOT_CONFIRMED_MAGIC) {
BKP_BOOT_ATTEMPTS = 0;
BKP_BOOT_CONFIRMED = 0; // Maak klaar voor de volgende cyclus
return true;
}
// Niet bevestigd: tel op
attempts++;
BKP_BOOT_ATTEMPTS = attempts;
if (attempts >= MAX_BOOT_ATTEMPTS) {
printf("[BL] WAARSCHUWING: %u opeenvolgende onbevestigde boots\n", attempts);
return false;
}
return true;
}
De volledige boot-flow
Op elke reset doorloopt de bootloader deze stappen:
- Initialiseer UART voor debug-output (115200 baud op USART3, ST-Link VCP)
- Lees en print de reset-oorzaak (power-on, watchdog, software reset, brownout, etc.)
- Activeer het backup-domein (toegang tot RTC-registers)
- Controleer option bytes: staan
nDBANK,nDBOOTenBOOT_ADD0correct? Zo niet: corrigeer en reset - Schrijfbeveilig sectors 0-1 (bootloader) via WRP option bytes
- Controleer de boot attempt counter: heeft de vorige boot zichzelf bevestigd?
- Lees BKP0R: staat er een update klaar?
- Ja (0xDEADBEEF): verifieer CRC van Bank 2 → wis Bank 1 → kopieer → verifieer kopie → clear flags
- Nee: sla over
- Valideer de vector table: staat de stack pointer in RAM (0x20000000–0x20080000)? Wijst de Reset_Handler naar app flash?
- Ongeldig: error LED blinken, halt
- Spring naar de applicatie op 0x08008000: zet VTOR, laad MSP en PC, en jump
Het cryptografische vertrouwensmodel
Het volledige beveiligingsmodel rust op één principe: de privésleutel voor het ondertekenen van firmware verlaat nooit de build server.
Dit werkt als volgt. De build server bezit de RSA private key. Bij elke firmware release berekent hij de SHA256-hash van het binair en ondertekent die hash met de private key. De resulterende handtekening reist mee met de MQTT-notificatie. Het device bevat alleen de RSA public key, gecompileerd in de firmware.
Met de public key kan het device verifiëren of een handtekening geldig is (en dus of de firmware van de build server komt), maar het kan zelf geen handtekeningen produceren. Dat is het asymmetrische principe van RSA: ondertekenen vereist de private key, verifiëren kan met de public key.
Kun je de private key afleiden uit de public key? Nee. RSA-beveiliging is gebaseerd op het wiskundige probleem van het factoriseren van zeer grote getallen. Voor een 2048-bit sleutel gaat het om een getal van ongeveer 617 cijfers. Dat is met huidige en voorzienbare technologie niet haalbaar.
De twee platformen gebruiken elk hun eigen signing-schema:
- ESP32: RSA-3072 via
espsecure.py(onderdeel van ESP-IDF). De handtekening zit ingebed in het binair zelf en wordt geverifieerd door de ESP-IDF bootloader bij het opstarten. - STM32: RSA-2048 + SHA256 via de Python
cryptographylibrary. De handtekening reist apart in de MQTT-notificatie en wordt geverifieerd door de ESP32 voordat de update naar de STM32 wordt gestuurd.
Waarom zowel SHA256 als CRC32?
Dit systeem gebruikt twee verschillende hash-algoritmen, en dat is geen overbodige luxe. Ze dienen fundamenteel verschillende doelen:
SHA256 is een cryptografische hash. In combinatie met de RSA-handtekening bewijst hij authenticiteit: komt deze firmware van onze build server? SHA256 is ontworpen zodat het wiskundig onhaalbaar is om twee verschillende inputs te vinden die dezelfde hash opleveren. Een aanvaller kan geen kwaadaardig binair maken dat dezelfde SHA256 oplevert als het origineel. De keerzijde: SHA256 is relatief zwaar om te berekenen, zeker op een microcontroller. CRC32 is géén cryptografische hash. Hij biedt geen bescherming tegen opzettelijke manipulatie, een aanvaller kan eenvoudig een binair construeren met dezelfde CRC32. Maar dat is niet zijn doel. CRC32 detecteert transmissiefouten: bitflips, incomplete writes, verstoorde UART-communicatie. Hij is extreem snel te berekenen en wordt daarom op drie momenten ingezet: bij ontvangst van elk UART-chunk, vóór het kopiëren van Bank 2 naar Bank 1, en ná het kopiëren.Kort gezegd: SHA256 beschermt tegen aanvallers, CRC32 beschermt tegen hardware- en communicatiefouten. Beide zijn nodig omdat ze complementaire bedreigingen afdekken.
Hoe het systeem aanvallen weerstaat
Theorie is mooi, maar hoe houdt dit systeem zich in de praktijk? Laten we de meest waarschijnlijke aanvalsscenario's langslopen.
1. Man-in-the-middle op de firmware download
De aanval: een aanvaller op het netwerk onderschept de HTTPS-download en vervangt de firmware door een kwaadaardig binair. Waarom het faalt: dit loopt vast op drie onafhankelijke lagen. De HTTPS-verbinding gebruikt TLS 1.2+ met certificaatvalidatie: de aanvaller heeft geen geldig certificaat voor het S3-domein. Maar zelfs als TLS op de een of andere manier wordt omzeild, faalt de RSA-signatuurverificatie, want de aanvaller kan geen geldige handtekening produceren zonder de private key. En zelfs als de signature-check wordt omzeild, faalt de CRC32-validatie op de STM32 (de verwachte CRC werd via het aparte MQTT-kanaal verzonden).Drie onafhankelijke checks. Elke individuele check stopt de aanval al.
2. Nep update-notificatie via MQTT
De aanval: een aanvaller publiceert een valse OTA-notificatie op hetgateway/ota/notify topic met een URL naar kwaadaardige firmware.
Waarom het faalt: AWS IoT Core gebruikt mutual TLS. Om te publiceren op een topic heb je een geldig device-certificaat nodig dat geregistreerd is in het device registry. Maar zelfs als een nep-notificatie wordt afgeleverd, kan de aanvaller wel elke URL en elk binair aanleveren, maar hij kan geen geldige RSA-2048 handtekening produceren. Zonder de private key is de signature_rsa in de notificatie waardeloos.
3. Fysieke toegang tot het device
De aanval: een aanvaller met fysieke toegang dumpt de ESP32-flash om geheimen te extraheren. Wat hij krijgt: de RSA public key (nutteloos voor ondertekenen), het device TLS-certificaat (publiek), de device TLS private key uit NVS (kan dit ene device imiteren op MQTT, maar kan geen firmware ondertekenen), en WiFi-credentials. Wat hij niet krijgt: de RSA signing private key. Die bestaat alleen op de build server. Fysieke toegang tot één device compromitteert de firmware signing pipeline niet. Het ergste scenario is dat de aanvaller dit ene device kan imiteren op MQTT, en dat is op te lossen door het certificaat in AWS IoT Core in te trekken.4. Replay attack: een oude update opnieuw afspelen
De aanval: een aanvaller vangt een geldige OTA-notificatie op en speelt hem later opnieuw af om een downgrade af te dwingen. Hoe het wordt beperkt: de gesigneerde firmware passeert de verificatie (het is legitiem gesigneerd), maar de pre-signed S3-URL is verlopen (15 tot 30 minuten geldig). Daarnaast kan het cloud backend downgrades afwijzen op basis van versienummers.Dit is een eerlijk punt: on-device anti-rollback versie-enforcement is er nog niet. Dat staat op de roadmap. De cloud-side versiecheck biedt bescherming, maar een on-device check zou robuuster zijn.
5. Supply chain attack: de build pipeline compromitteren
De aanval: een aanvaller krijgt toegang tot de CI/CD-pipeline en injecteert kwaadaardige code in de firmware build. Waarom dit de hoogste risicovector is: als een aanvaller de repository én de signing secrets in handen krijgt, kan hij legitiem gesigneerde kwaadaardige firmware produceren. Dit is de enige aanval die het cryptografische model niet tegenhoudt. Hoe het wordt beperkt: de signing keys zijn opgeslagen als GitHub Secrets (versleuteld, alleen beschikbaar voor de release workflow), branch protection voorkomt ongeautoriseerde commits opmaster, de release workflow triggert alleen op version tags, en code review is verplicht.
De cryptografische keten is zo sterk als de toegangscontroles rondom de signing keys. Dit is waar organisatorische beveiliging (twee-factorauthenticatie, beperkte toegang, audit logging) minstens zo belangrijk is als de technische implementatie.
Het gelaagde beveiligingsmodel
Het systeem vertrouwt niet op één beveiligingsmechanisme. Elke laag is onafhankelijk, en zelfs als één laag volledig gecompromitteerd wordt, beschermen de andere lagen het device:
Laag 1, Netwerktoegang: WiFi WPA2/WPA3, MQTT mTLS, HTTPS TLS 1.2+.
Laag 2, Authenticiteit: RSA digitale handtekening + SHA256 hash, geverifieerd vóór elke flash-operatie.
Laag 3, Integriteit: CRC32-controle bij ontvangst, vóór het kopiëren, en ná het kopiëren van firmware.
Laag 4, Recovery: ESP32 automatische rollback + factory partition. STM32 verify-before-erase + boot attempt counter + retry-logica.
Een aanvaller zou tegelijkertijd TLS moeten doorbreken, een RSA-handtekening moeten vervalsen, én een CRC32 moeten matchen. Elk van deze stappen is op zichzelf al onhaalbaar.
Wat er nog niet is, en wanneer het relevant wordt
De kern staat, maar er zijn bewuste keuzes gemaakt om bepaalde features nog niet te implementeren:
- ESP32 Secure Boot: hardware-afgedwongen verificatie van de hele opstartketen. Wordt relevant zodra fysieke toegang tot het device een reëel risico is in het deploymentscenario, bijvoorbeeld bij publiek toegankelijke installaties.
- Flash-encryptie: beschermt de firmware op de chip tegen uitlezen. Interessant wanneer de firmware zelf intellectueel eigendom bevat dat beschermd moet worden, of wanneer gevoelige configuratiedata (zoals certificaten) extra bescherming nodig heeft.
- Key rotation: de ingebedde RSA public key vereist momenteel een firmware update om te wijzigen. Een key rotation mechanisme wordt relevant bij langlopende deployments (5+ jaar) waar de kans toeneemt dat een sleutel gecompromitteerd raakt.
- Rollback versie-enforcement: er is nog geen on-device check die voorkomt dat een oudere firmware versie wordt geïnstalleerd. Dit wordt prioriteit zodra het systeem naar productie gaat met meerdere firmware versies in het veld, zodat een aanvaller geen bekende kwetsbaarheden kan terugbrengen via een downgrade.
Elk van deze features voegt waarde toe. Het huidige systeem dekt de kern af: authenticiteit, integriteit, en beschikbaarheid. De genoemde features zijn logische vervolgstappen wanneer het dreigingsmodel dat vereist.
Demo
Hieronder twee opnames van het werkende systeem: de STM32 die een volledige firmware-update ontvangt via de ESP32 gateway, en de ESP32 die zichzelf bijwerkt via AWS.
STM32 OTA update demo: volledige firmware-update over UART vanuit de ESP32 gateway
ESP32 OTA update demo: firmware downloaden van AWS en uitrollen naar de gateway
Conclusie
OTA updates in OT-omgevingen zijn geen feature die je er aan het eind bij bouwt. Ze zijn een risico dat je vanaf het begin moet beheersen. Het verschil tussen een prototype en een productiesysteem zit niet in het kunnen downloaden van een binary, maar in wat er gebeurt als dat misgaat.
De kern van een veilige OTA-implementatie is eigenlijk verrassend eenvoudig: verifieer voor je wist, vertrouw het netwerk niet, en zorg dat er altijd een werkende firmware beschikbaar blijft. De cryptografische verificatieketen en de meervoudige fallback-mechanismen zijn er om die principes af te dwingen, zelfs wanneer het netwerk, de hardware, of de update zelf niet meewerkt.
Hopelijk geeft dit artikel je een concrete basis om zelf aan de slag te gaan met veilige OTA updates. De specifieke implementatie (ESP32 + STM32) is slechts één invulling. De principes, verify-before-erase, asymmetrische firmware-signing, gelaagde beveiliging, en automatische rollback, zijn universeel toepasbaar op elk embedded platform.
Wil je OTA updates implementeren in jouw embedded product?
Van architectuur tot veilige firmware delivery, ik help je met een OTA-oplossing die past bij jouw OT-omgeving.