OTA con firma criptográfica: qué resuelve y por qué importa

La mayoría de los tutoriales de OTA en ESP32 cubren la mecánica (descargar un binario, escribirlo, reiniciar) pero ignoran la parte más importante: ¿cómo evitar que cualquiera empuje firmware malicioso a tu dispositivo?. Si tu ESP32 está expuesto a internet o comparte red con dispositivos que no controlas, una OTA sin verificación es una puerta abierta.

Esta guía construye un sistema OTA donde el ESP32 solo acepta firmware que tú firmaste personalmente con tu clave privada. Cualquier otro binario es rechazado antes de arrancar por el Lifecycle Manager (LCM). No llega ni a ejecutar. El sistema se compone de dos piezas:

Parte 1. Lifecycle Manager (LCM): un firmware "portero" que se instala primero. Gestiona WiFi, descarga actualizaciones desde GitHub y verifica firmas. Es lo único que decide qué firmware está autorizado a correr.

Parte 2. Tu firmware de aplicación: el código que realmente hace algo útil (LED, sensor, lo que sea). LCM lo descarga, verifica su firma y solo entonces lo instala.

La regla a recordar: el firmware debe estar firmado y la firma debe coincidir con la clave pública embebida en LCM. Si no coinciden, se rechaza. Eso no es un error, es exactamente el comportamiento que buscamos.

Términos que vas a ver:

  • Clave privada (ota_signing_private.pem): el archivo secreto que firma el binario. Si lo pierdes o se filtra, la seguridad cae.
  • Clave pública (ota_signing_public.pem): se embebe en LCM. No es secreta: sirve para verificar.
  • Firma: hash criptográfico del binario, generado con la clave privada.

Hardware y software necesarios

Hardware:

  • ESP32 WROOM 32D (placa con USB integrado, recomendada)

Placa ESP32 WROOM 32D con USB integrado lista para programar

  • Cable USB de datos
  • 1 LED + resistencia de 220 Ω a 1 kΩ
  • 1 pulsador (para reset físico)
  • 2 jumpers macho-macho
  • Protoboard
  • WiFi 2.4 GHz
  • iPhone/iPad con Apple Home

Software:

  • Docker Desktop
  • Git
  • Python 3 + esptool.py
  • OpenSSL (viene incluido en macOS/Linux; en Windows ver Git Bash o WSL)
  • Pulsar (o cualquier editor de código)

Preparar el entorno ESP IDF

Si ya hiciste el tutorial de HomeKit para principiantes, este paso te lo sabes. Si no, en resumen:

  1. Instala Docker Desktop desde docker.com/products/docker-desktop y reinicia
  2. Descarga la imagen de ESP IDF:
Bash
docker pull espressif/idf:v5.4
  1. Instala Git y Python 3
  2. Instala esptool:
Bash
pip3 install esptool
  1. Verifica:
Bash
docker --version
git --version
python3 --version
esptool.py version

Listo.

Descargar el código del Lifecycle Manager

Bash
git clone https://github.com/AchimPieters/esp32-lifecycle-manager.git
cd esp32-lifecycle-manager

Generar tus claves de firma: el paso crítico

Esto va ahora, no después. Las claves se generan una sola vez y se reutilizan para todos tus dispositivos.

Bash
openssl ecparam -name prime256v1 -genkey -noout -out ota_signing_private.pem
openssl ec -in ota_signing_private.pem -pubout -out ota_signing_public.pem

Salida:

  • ota_signing_private.pem → guárdalo en un lugar seguro (gestor de contraseñas, USB cifrado, no en GitHub)
  • ota_signing_public.pem → este lo vas a embeber en LCM

El algoritmo usado es ECDSA con curva prime256v1 (también conocida como P256), más eficiente que RSA en microcontroladores y suficientemente fuerte para este uso.

Embeber la clave pública en LCM

Abre el proyecto en Pulsar (o cualquier editor):

  1. Archivo → Abrir carpeta → esp32-lifecycle-manager
  2. Abre ota_signing_public.pem y copia todo el texto
  3. Abre main/github_update.c
  4. Busca static const char OTA_PUBLIC_KEY_PEM[]
  5. Reemplaza el contenido por tu clave pública

Editor mostrando github_update.c con la constante OTA_PUBLIC_KEY_PEM lista para reemplazar

Si rompes algo, recuerda: el repo está intacto en GitHub. git checkout main/github_update.c deshace los cambios. No puedes hacer daño real explorando.

Compilar LCM

Bash
docker run -it -v ~/esp32-lifecycle-manager:/project -w /project espressif/idf:v5.4.2

Dentro del contenedor:

Bash
idf.py set-target esp32
idf.py build

Cuando termina, deja esta ventana abierta: vas a copiar el comando de flasheo desde la salida.

Compilar tu firmware de aplicación (ejemplo LED)

En otra terminal (sin cerrar la anterior):

Bash
docker run -it -v ~/esp32-lifecycle-manager:/project -w /project espressif/idf:v5.4
cd examples/led
idf.py set-target esp32
idf.py build

Resultado: build/main.bin.

Firmar tu firmware

En otra terminal nueva (no dentro de Docker):

Bash
cd ~/esp32-lifecycle-manager
./generate_sig.sh examples/led/build/main.bin ota_signing_private.pem

Esto genera:

  • main.bin (sin tocar)
  • main.bin.sig (la firma)

Ambos archivos van juntos en cada actualización.

Subir a GitHub para que LCM lo descargue

  1. Crea un repositorio nuevo en tu cuenta de GitHub (ej: firmware-mi-luz)
  2. Crea un release con tag de versión (ej: 1.0.0)
  3. Sube los dos archivos: main.bin y main.bin.sig

LCM descargará desde la URL pública del release, así que el repo debe ser público o accesible.

Release de GitHub con los archivos main.bin y main.bin.sig adjuntos

Cableado del LED + pulsador

Código
GPIO2 → resistencia 220Ω → ánodo del LED → cátodo del LED → GND
GPIO0 → pulsador → GND

GPIO2 maneja el LED (mismo del ejemplo HomeKit). GPIO0 con pulsador permite el reset por software del LCM.

Diagrama de conexión del LED y el pulsador a la placa ESP32 en protoboard

Erase + flash de LCM al ESP32

Sal de Docker (Ctrl+D) y desde el sistema:

Bash
esptool.py erase_flash

Anota el nombre del puerto serial (ej: /dev/cu.usbserial-01FD1166).

Vuelve a la terminal Docker donde compilaste LCM y copia el comando de flasheo que dejaste abierto. Algo así:

Bash
python -m esptool --chip esp32 -b 460800 \
  --before default_reset --after hard_reset write_flash \
  --flash_mode dio --flash_size 4MB --flash_freq 40m \
  0x1000 build/bootloader/bootloader.bin \
  0x8000 build/partition_table/partition-table.bin \
  0xe000 build/ota_data_initial.bin \
  0x20000 build/esp32-lifecycle-manager.bin

Pega y enter.

Verificar con monitor serial

Bash
screen /dev/cu.usbserial-XXXX 115200

Mensajes esperados al boot:

  • Boot info (chip, memoria) → firmware corre
  • "Starting AP interface" → LCM levantó un Access Point para configuración
  • "Starting DNS server"
  • "Starting HTTP server"

Salir: Ctrl+A, K, Y.

Configurar LCM por portal cautivo

LCM levanta un AP LCM-XXXXXX que aparece en las redes WiFi disponibles. En macOS puede demorar un par de minutos en aparecer.

  1. Conéctate al AP LCM-XXXXXX
  2. El portal cautivo se abre automáticamente
  3. Elige tu red WiFi de casa y escribe la contraseña
  4. Firmware source: pon tu repo (ej: tu-usuario/firmware-mi-luz)
  5. Activa "LED durante update" si quieres feedback visual (default GPIO2, nivel ON)
  6. Confirma

Portal cautivo de LCM para elegir la red WiFi y configurar el repositorio de firmware en GitHub

LCM va a:

  1. Descargar main.bin + main.bin.sig desde tu release
  2. Verificar que la firma coincide con la clave pública embebida
  3. Si OK: escribir flash y reiniciar
  4. Si firma falla: rechazar, mantener firmware anterior, registrar el evento

Cómo updatear después

Cuando termines un cambio en tu firmware:

  1. Compila otra vez:
    Bash
    idf.py build
    
  2. Firma:
    Bash
    ./generate_sig.sh examples/led/build/main.bin ota_signing_private.pem
    
  3. Crea un nuevo release en GitHub (ej: 1.0.1) y sube los dos archivos
  4. Desde la app Eve en iOS, gatilla la actualización del accesorio HomeKit

LCM descarga, verifica la firma, instala y reinicia. Cero intervención física en el hardware.

Recuperación y resets

LCM soporta tres mecanismos:

Método Cómo se gatilla Qué hace
Firmware update App Eve → update Descarga la última release del repo configurado
Software factory reset API LCM o pulsador Borra la config WiFi y vuelve al portal cautivo
Hardware factory reset 10 ciclos de apagado y encendido Si LCM detecta el patrón, espera ~11 s y borra la config

El hardware reset es útil cuando el firmware quedó corrupto y ni el portal responde: desconecta y conecta el equipo 10 veces seguidas y LCM hace la recuperación automática.

Variantes y mejoras

  1. OTA desde un servidor propio en lugar de GitHub: si no quieres que tus binarios estén públicos, monta un servidor HTTPS (un VPS pequeño con nginx sirve) y apunta Firmware source en LCM a https://tu-server.cl/firmware/. Mantén un endpoint que devuelva la última versión disponible (puede ser simplemente un JSON con la URL del binario actual).

  2. Rotación de claves: si sospechas que tu ota_signing_private.pem se filtró, no puedes simplemente generar una nueva clave: los dispositivos ya tienen embebida la pública vieja. Para resolverlo, agrega un mecanismo de "doble clave" en LCM: que acepte firmas de la pública A o de la pública B, y emite un firmware de transición que reemplaza la clave A por una nueva C, antes de descomisionar la A. Esto requiere modificar github_update.c.

  3. Múltiples ESP32 con la misma clave: una sola clave privada puede firmar firmware para una flota de dispositivos. Útil si tienes 5 luces inteligentes en casa o si vas a fabricar varios accesorios DIY. La seguridad operativa pasa por mantener la clave privada en un solo equipo confiable.

Personalización para Chile

Componente Producto en MechatronicStore SKU Precio CLP
Placa ESP32 WROOM 32D ESP32 WROOM 32 Tipo C X2-10V2 $7.990
LED 5mm LED 5mm rojo GA1-5 $100
Resistencia 220 Ω Resistencia 1/4W 220Ω GK1-18 $100
Pulsador 12x12x8 Boton pulsador switch 4 pines push button 12x12x8mm GA6-16 $290
Protoboard 830 puntos Breadboard MB102 830 puntos C-302 $3.790
Cable USB Micro Cable USB a Micro USB X3-10 $1.290
Jumpers macho-macho 20cm Cables macho-macho 40 piezas 20cm C-411 $1.990

Costo total estimado: ~$15.550 CLP.

Nota sobre OpenSSL en Windows: si trabajas en Windows nativo (sin WSL), instala OpenSSL desde slproweb.com/products/Win32OpenSSL.html o usa Git Bash, que ya lo incluye. Los comandos openssl ecparam y openssl ec funcionan igual.

Recursos