
MQTT con TLS en el ESP32: valida el certificado del broker y deja de usar setInsecure
El ejemplo con setInsecure() cifra, pero acepta cualquier servidor. CA propia, Mosquitto en el puerto 8883, hora por NTP, heap y las pruebas negativas que demuestran la validación.
Requisitos de hardware:
- Placa ESP32 ou ESP32-S3
- Rede Wi-Fi de 2,4 GHz com acesso a um servidor NTP
- Um broker Eclipse Mosquitto 2.x e o OpenSSL no computador que o administra
Descarga el material de este tutorial:
- Projeto completo (ZIP)14 KBFirmware Arduino com validação do certificado do broker, guarda de hora válida, Last Will e log de falha com o motivo do mbedTLS. Script de certificados, configuração do Mosquitto, README e manual em PDF. Não foi compilado em placa.
- Manual em PDF (5 páginas)13 KBAs quatro armadilhas, CA e certificado do broker, Mosquitto, os três testes negativos, tabela de problemas e checklist.
¿Quieres recibir nuevos manuales y esquemas industriales? Únete a nuestro canal de Telegram / WhatsApp
El cifrado que no protege de nada
Busca MQTT con TLS en el ESP32 y el primer ejemplo que aparece termina igual: el código crea un WiFiClientSecure, llama a setInsecure() y la conexión por el puerto 8883 pasa. El monitor serie dice "conectado", el tráfico sale cifrado y parece resuelto.
No lo está. setInsecure() activa el cifrado pero desactiva la verificación de quién está al otro lado. El ESP32 acepta el certificado de cualquier servidor que responda en esa dirección. Un router adulterado, un punto de acceso falso o un ordenador en medio de la red presenta su propio certificado, tu dispositivo lo acepta, y el usuario y la contraseña de MQTT llegan a quien montó la trampa. Cifrar sin validar es cerrar una puerta de cristal y dejar la llave colgada en ella.
TLS entrega tres cosas: confidencialidad (nadie lee), integridad (nadie altera por el camino) y autenticación del servidor (hablas de verdad con tu broker). La tercera es la que setInsecure() descarta, y es justamente la que impide el ataque de intermediario. Este artículo muestra cómo conseguir las tres, con un certificado propio y sin atajos.
Qué cambia respecto al puerto 1883
En el puerto 1883, el paquete CONNECT de MQTT lleva el usuario y la contraseña sin ninguna protección. Cualquier equipo en el camino entre el ESP32 y el broker lee ambos, y también todo el payload. En un banco de pruebas aislado eso pasa. En una red de fábrica compartida, en un Wi-Fi de visitas o en internet, no.
El puerto 8883 es MQTT sobre TLS. El ESP32 hace primero el handshake TLS, valida el certificado del broker contra una autoridad certificadora (CA) que ya conoce y solo entonces envía el CONNECT, ya dentro del canal cifrado.
Las cuatro trampas que hunden el primer intento
1. El reloj. Un certificado tiene fecha de inicio y de fin, y el ESP32 arranca creyendo que estamos en 1970. Con esa fecha, todo certificado es "todavía no válido" y el handshake falla. La salida es sincronizar la hora por NTP antes de intentar TLS. Sin red hacia el NTP no hay conexión segura, y el firmware debe aceptarlo en lugar de insistir.
2. El nombre del broker. El certificado se emite para un nombre, y la dirección que usa el firmware al conectar debe coincidir con él. Conectar por IP a un certificado emitido para un nombre falla en la validación. La forma segura es usar un nombre DNS (o una entrada en el DNS de tu router) y ponerlo en el certificado. También existe el certificado con la IP en los nombres alternativos, pero el soporte depende de la versión de mbedTLS de tu núcleo, así que pruébalo antes de depender de ello.
3. La memoria. El handshake TLS reserva buffers grandes, del orden de decenas de KB de heap. En un proyecto que ya carga servidor web, JSON y bibliotecas, la conexión puede fallar por falta de memoria, y el error no lo dice con claridad. Mide ESP.getFreeHeap() antes y después de conectar, y deja margen.
4. Qué grabar en el firmware. Graba la CA que firmó el certificado del broker, y no el certificado del propio broker. El certificado del servidor caduca y se cambia; la CA dura años. Si la CA es privada, tú decides su validez y debes planificar el cambio antes de que caduque, porque viaja dentro del firmware. El camino para cambiarla sin ir hasta el equipo está en el artículo de actualización OTA.
Paso 1: crear tu CA y el certificado del broker
Para una red interna, una CA privada resuelve y no cuesta nada. En el ordenador desde el que administras el broker, con OpenSSL instalado:
| 1 | openssl genrsa -out ca.key 4096 |
| 2 | openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -subj "/CN=IIoT Lab CA" -out ca.crt |
| 3 | openssl genrsa -out broker.key 2048 |
| 4 | openssl req -new -key broker.key -subj "/CN=broker.lab.local" -out broker.csr |
| 5 | printf "subjectAltName=DNS:broker.lab.local" > san.ext |
| 6 | openssl x509 -req -in broker.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out broker.crt -days 825 -sha256 -extfile san.ext |
Cambia broker.lab.local por el nombre que usará tu ESP32. Ahora tienes una pila de archivos nuevos. Dos importan fuera de aquí: ca.crt, que es público y va al firmware, y ca.key, que firma certificados y no puede quedarse en el broker ni en un repositorio. Guárdalo sin conexión, en una copia de seguridad a la que solo tú accedas.
Paso 2: configurar Mosquitto
Copia ca.crt, broker.crt y broker.key al servidor (por ejemplo en /etc/mosquitto/certs), con la clave legible solo por el usuario de Mosquitto. En el archivo de configuración:
| 1 | listener 8883 |
| 2 | cafile /etc/mosquitto/certs/ca.crt |
| 3 | certfile /etc/mosquitto/certs/broker.crt |
| 4 | keyfile /etc/mosquitto/certs/broker.key |
| 5 | allow_anonymous false |
| 6 | password_file /etc/mosquitto/passwd |
| 7 | listener 1883 127.0.0.1 |
La última línea mantiene el puerto sin TLS, pero solo para quien está en el propio servidor. Reinicia el servicio y demuestra que el certificado es correcto antes de tocar el ESP32:
| 1 | openssl s_client -connect broker.lab.local:8883 -CAfile ca.crt |
Al final de la salida debe aparecer Verify return code: 0 (ok). Si aparece otro código, el problema es del certificado o del nombre, y el ESP32 solo repetirá el mismo error con menos información. Una segunda prueba confirma la autenticación:
| 1 | mosquitto_pub -h broker.lab.local -p 8883 --cafile ca.crt -u esp32-lab -P SENHA_MQTT -t lab/teste -m ok |
Si no hay mensaje de error, es señal de éxito.
Paso 3: el firmware
El patrón es el mismo del artículo de reconexión automática de MQTT: una función de guarda llamada en cada vuelta de loop(), sin bucle bloqueante. La diferencia es una guarda más, la de la hora válida.
| 1 | #include <WiFi.h> |
| 2 | #include <WiFiClientSecure.h> |
| 3 | #include <PubSubClient.h> |
| 4 | #include <time.h> |
| 5 | const char* WIFI_SSID = "REDE_WIFI"; |
| 6 | const char* WIFI_PASSWORD = "SENHA_WIFI"; |
| 7 | const char* MQTT_HOST = "broker.lab.local"; |
| 8 | const uint16_t MQTT_PORT = 8883; |
| 9 | const char* MQTT_USER = "esp32-lab"; |
| 10 | const char* MQTT_PASSWORD = "SENHA_MQTT"; |
| 11 | const time_t MIN_VALID_EPOCH = 1700000000; |
| 12 | const char CA_CERT[] = R"EOF( |
| 13 | -----BEGIN CERTIFICATE----- |
| 14 | PEGA AQUI EL CONTENIDO DEL ARCHIVO ca.crt |
| 15 | -----END CERTIFICATE----- |
| 16 | )EOF"; |
| 17 | WiFiClientSecure tlsClient; |
| 18 | PubSubClient mqtt(tlsClient); |
| 19 | uint32_t lastAttemptMs = 0; |
MIN_VALID_EPOCH es un instante cualquiera posterior a la fecha en que escribiste el firmware. Cualquier hora menor que esa significa que el NTP aún no ha respondido. Ahora la función de conexión:
| 1 | void connectMqtt() { |
| 2 | if (mqtt.connected() || WiFi.status() != WL_CONNECTED) return; |
| 3 | if (time(nullptr) < MIN_VALID_EPOCH) return; |
| 4 | if (millis() - lastAttemptMs < 5000) return; |
| 5 | lastAttemptMs = millis(); |
| 6 | char clientId[24]; |
| 7 | snprintf(clientId, sizeof(clientId), "esp32-%06X", (uint32_t)(ESP.getEfuseMac() >> 24)); |
| 8 | if (mqtt.connect(clientId, MQTT_USER, MQTT_PASSWORD, "lab/esp32/status", 1, true, "offline")) { |
| 9 | mqtt.publish("lab/esp32/status", "online", true); |
| 10 | } else { |
| 11 | char tlsErr[100] = ""; |
| 12 | tlsClient.lastError(tlsErr, sizeof(tlsErr)); |
| 13 | Serial.printf("MQTT rc=%d tls=[%s] heap=%u\n", mqtt.state(), tlsErr, ESP.getFreeHeap()); |
| 14 | } |
| 15 | } |
El registro de fallos es la parte que ahorra horas. El rc=-2 de PubSubClient solo dice que la conexión de red falló; el lastError() del cliente seguro añade el motivo de mbedTLS (verificación del certificado, falta de memoria, tiempo agotado), y el heap restante muestra si la causa fue la memoria. El resto del programa:
| 1 | void setup() { |
| 2 | Serial.begin(115200); |
| 3 | tlsClient.setCACert(CA_CERT); |
| 4 | mqtt.setServer(MQTT_HOST, MQTT_PORT); |
| 5 | WiFi.mode(WIFI_STA); |
| 6 | WiFi.begin(WIFI_SSID, WIFI_PASSWORD); |
| 7 | configTime(0, 0, "pool.ntp.org", "time.google.com"); |
| 8 | } |
| 9 | void loop() { |
| 10 | connectMqtt(); |
| 11 | mqtt.loop(); |
| 12 | } |
Una salvedad honesta: el handshake TLS es bloqueante y puede tardar desde cientos de milisegundos hasta unos segundos, según la señal y el broker. Es un bloqueo corto y acotado, muy distinto del bucle infinito, pero si tu proyecto tiene una tarea crítica en tiempo, ejecuta MQTT en una tarea separada de FreeRTOS. Y con el watchdog activo, comprueba que tolera ese tiempo.
Paso 4: qué debes ver
En el monitor serie, con la hora sincronizada, la conexión ocurre sola y el tema lab/esp32/status pasa a valer "online". Para verlo, en otra terminal:
| 1 | mosquitto_sub -h broker.lab.local -p 8883 --cafile ca.crt -u esp32-lab -P SENHA_MQTT -t "lab/esp32/#" -v |
Después haz la prueba que importa, la negativa. Si la validación real está activa, tiene que rechazar lo que no es tuyo:
- Cambia el nombre del broker en el firmware por una dirección IP que no esté en el certificado: la conexión debe fallar con un error de verificación.
- Altera una letra del contenido de ca.crt en el firmware: la conexión debe fallar.
- Bloquea el NTP (puerto 123 en el router) y reinicia: el firmware debe quedarse esperando sin abrir conexión, y nunca conectar con la hora de 1970.
Si alguna de estas tres conecta, la validación no está activa. Eso vale más que ver aparecer el "online".
Cuando algo no funciona
rc=-2 y un error de verificación X.509 en lastError(). El certificado fue rechazado: la CA grabada no es la que firmó el del broker, el nombre no coincide o el certificado caducó. Repite el openssl s_client del paso 2.
rc=-2 sin texto en lastError() y heap bajo. Falta memoria para el handshake. Quita bibliotecas, reduce buffers o usa un módulo con más RAM.
Ningún intento aparece en el monitor serie. La guarda de la hora está bloqueando. Comprueba el acceso al NTP, o apunta a un servidor NTP de tu red.
Funcionó en el banco y falló en el cliente. El nombre DNS del broker no se resuelve en su red, o el puerto 8883 está bloqueado. El cortafuegos es el sospechoso más común.
Funcionó meses y se detuvo. Revisa la validez del certificado del broker y de la CA. El certificado del broker caduca antes y puede renovarse en el servidor sin tocar los dispositivos, siempre que la CA sea la misma.
Qué no cubre este artículo
- Certificado de cliente (TLS mutuo). Da a cada ESP32 una identidad propia en lugar de una contraseña compartida. En Mosquitto son las opciones require_certificate y use_identity_as_username; en el ESP32, setCertificate() y setPrivateKey(). Aumenta el trabajo de emitir y revocar, y compensa cuando hay muchos dispositivos.
- Permisos por tema. TLS prueba quién conecta, pero no limita lo que cada uno publica. Eso es la ACL del broker.
- Protección de la clave en el dispositivo. La CA es pública, pero la contraseña de MQTT grabada en el firmware no. El cifrado de la flash y el arranque seguro del ESP32 entran aquí, y merecen un artículo propio.
Nota de honestidad
El código de este artículo sigue la API de WiFiClientSecure y PubSubClient del núcleo Arduino-ESP32, y los comandos de OpenSSL y Mosquitto son los estándar. Aun así, no fue compilado ni probado en una placa en este momento. Antes de llevarlo a campo, compílalo en tu entorno, ejecuta las tres pruebas negativas y comprueba el heap con tu aplicación completa.
Para publicar datos una vez tengas la conexión segura, el punto de partida es el artículo ESP32 publicando datos en Mosquitto. Si la red cae, el búfer local guarda las lecturas hasta que vuelva la conexión.
