
MQTT com TLS no ESP32: valide o certificado do broker e pare de usar setInsecure
O exemplo com setInsecure() cifra, mas aceita qualquer servidor. CA própria, Mosquitto na porta 8883, hora por NTP, heap e os testes negativos que provam a validação.
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
Baixe o material deste 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.
Quer receber novos manuais e esquemas industriais? Entre no nosso canal do Telegram / WhatsApp
A criptografia que não protege de nada
Procure por MQTT com TLS no ESP32 e o primeiro exemplo que aparece termina assim: o código cria um WiFiClientSecure, chama setInsecure() e a conexão na porta 8883 passa. O Serial mostra "conectado", o tráfego sai cifrado e parece resolvido.
Não está. O setInsecure() liga a cifra, mas desliga a verificação de quem está do outro lado. O ESP32 aceita o certificado de qualquer servidor que responda naquele endereço. Um roteador adulterado, um ponto de acesso falso ou um computador no meio da rede apresenta um certificado próprio, o seu dispositivo aceita, e usuário e senha do MQTT chegam em texto claro a quem montou a armadilha. Cifrar sem validar é fechar a porta de vidro e deixar a chave pendurada nela.
TLS entrega três coisas: sigilo (ninguém lê), integridade (ninguém altera no caminho) e autenticação do servidor (você fala mesmo com o seu broker). A terceira é a que o setInsecure() descarta, e é justamente a que impede o ataque de homem no meio. Este artigo mostra como fazer as três, com um certificado seu e sem atalho.
O que muda em relação à porta 1883
Na porta 1883, o pacote CONNECT do MQTT leva o usuário e a senha sem nenhuma proteção. Qualquer equipamento no caminho entre o ESP32 e o broker lê os dois, e também todo o payload. Em bancada isolada isso passa. Numa rede de fábrica compartilhada, num Wi-Fi de visitantes ou na internet, não passa.
A porta 8883 é o MQTT sobre TLS. O ESP32 faz primeiro o aperto de mãos TLS, valida o certificado do broker contra uma autoridade certificadora (CA) que ele já conhece e só então envia o CONNECT, já por dentro do canal cifrado.
As quatro armadilhas que derrubam a primeira tentativa
1. O relógio. Um certificado tem data de início e de fim, e o ESP32 liga achando que estamos em 1970. Com essa data, todo certificado é "ainda não válido" e o aperto de mãos falha. A saída é sincronizar a hora por NTP antes de tentar o TLS. Sem rede para o NTP, não há conexão segura, e o firmware precisa aceitar isso em vez de insistir.
2. O nome do broker. O certificado é emitido para um nome, e o endereço que o firmware usa na conexão precisa bater com esse nome. Conectar por IP num certificado emitido para um nome falha na validação. A forma segura é usar um nome DNS (ou uma entrada no DNS do seu roteador) e colocá-lo no certificado. Certificado com o IP no campo de nomes alternativos também existe, mas o suporte depende da versão do mbedTLS que seu núcleo traz, então teste antes de depender disso.
3. A memória. O aperto de mãos TLS aloca buffers grandes, na casa das dezenas de KB de heap. Num projeto que já carrega servidor web, JSON e bibliotecas, a conexão pode falhar por falta de memória, e o erro não diz isso com clareza. Meça ESP.getFreeHeap() antes e depois de conectar, e deixe margem.
4. O que gravar no firmware. Grave a CA que assinou o certificado do broker, e não o certificado do próprio broker. O certificado do servidor vence e é trocado; a CA dura anos. Se a CA for privada, você decide a validade, e precisa planejar a troca antes do vencimento, porque ela viaja dentro do firmware. O caminho para trocar sem ir até o equipamento está no artigo de atualização OTA.
Passo 1: criar a sua CA e o certificado do broker
Para uma rede interna, uma CA privada resolve e não custa nada. No computador onde você administra o broker, com o 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 |
Troque broker.lab.local pelo nome que o seu ESP32 vai usar. Você deve ter agora uma pilha de arquivos novos. Dois importam fora daqui: ca.crt, que é público e vai para o firmware, e ca.key, que assina certificados e não pode ficar no broker nem em repositório. Guarde-o offline, num lugar de backup que só você acessa.
Passo 2: configurar o Mosquitto
Copie ca.crt, broker.crt e broker.key para o servidor (por exemplo, em /etc/mosquitto/certs), com a chave legível apenas pelo usuário do Mosquitto. No arquivo de configuração:
| 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 |
A última linha mantém a porta sem TLS, mas só para quem está no próprio servidor. Reinicie o serviço e prove que o certificado está certo antes de tocar no ESP32:
| 1 | openssl s_client -connect broker.lab.local:8883 -CAfile ca.crt |
No fim da saída deve aparecer Verify return code: 0 (ok). Se aparecer outro código, o problema é do certificado ou do nome, e o ESP32 só vai repetir o mesmo erro com menos informação. Um segundo teste confirma a autenticação:
| 1 | mosquitto_pub -h broker.lab.local -p 8883 --cafile ca.crt -u esp32-lab -P SENHA_MQTT -t lab/teste -m ok |
Sem mensagem de erro é sinal de sucesso.
Passo 3: o firmware
O padrão é o mesmo do artigo de reconexão automática do MQTT: uma função de guarda chamada a cada volta do loop(), sem laço bloqueante. A diferença é uma guarda a mais, a da 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 | COLE AQUI O CONTEUDO DO ARQUIVO ca.crt |
| 15 | -----END CERTIFICATE----- |
| 16 | )EOF"; |
| 17 | WiFiClientSecure tlsClient; |
| 18 | PubSubClient mqtt(tlsClient); |
| 19 | uint32_t lastAttemptMs = 0; |
O valor MIN_VALID_EPOCH é um instante qualquer posterior à data em que você escreveu o firmware. Qualquer hora menor que isso significa que o NTP ainda não respondeu. Agora a função de conexão:
| 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 | } |
O registro de falha é a parte que economiza horas. O código rc=-2 do PubSubClient só diz que a conexão de rede falhou; o lastError() do cliente seguro acrescenta o motivo do mbedTLS (verificação do certificado, falta de memória, tempo esgotado), e o heap restante mostra se a causa foi memória. O restante do 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 | } |
Uma ressalva honesta: o aperto de mãos TLS é bloqueante, e pode levar de centenas de milissegundos a alguns segundos, conforme o sinal e o broker. É um bloqueio curto e limitado, bem diferente do laço infinito, mas se o seu projeto tem uma tarefa crítica de tempo, rode o MQTT numa tarefa separada do FreeRTOS. E com o watchdog ativo, confira que ele tolera esse tempo.
Passo 4: o que você deve ver
No monitor serial, com a hora sincronizada, a conexão acontece sozinha e o tópico lab/esp32/status passa a valer "online". Para assistir, em outro terminal:
| 1 | mosquitto_sub -h broker.lab.local -p 8883 --cafile ca.crt -u esp32-lab -P SENHA_MQTT -t "lab/esp32/#" -v |
Depois faça o teste que importa, o negativo. Se a validação de verdade está ligada, ela tem que reprovar o que não é seu:
- Troque o nome do broker no firmware por um endereço IP que não esteja no certificado: a conexão deve falhar com erro de verificação.
- Altere uma letra do conteúdo de ca.crt no firmware: a conexão deve falhar.
- Bloqueie o NTP (porta 123 no roteador) e reinicie: o firmware deve ficar tentando sem abrir conexão, e nunca conectar com a hora de 1970.
Se alguma dessas três coisas conectar, a validação não está ativa. Isso vale mais que ver o "online" aparecer.
Quando algo não funciona
rc=-2 e um erro de verificação do X.509 no lastError(). O certificado foi recusado: a CA gravada não é a que assinou o do broker, o nome não bate ou o certificado venceu. Repita o openssl s_client do passo 2.
rc=-2 sem texto no lastError() e o heap baixo. Falta memória para o aperto de mãos. Tire bibliotecas, reduza buffers ou use um módulo com mais RAM.
Nenhuma tentativa aparece no Serial. A guarda da hora está barrando. Confira o acesso ao NTP, ou aponte para um servidor NTP da sua rede.
Funcionou na bancada e falhou no cliente. O nome DNS do broker não resolve na rede dele, ou a porta 8883 está bloqueada. O firewall é o suspeito mais comum.
Passou meses funcionando e parou. Confira a validade do certificado do broker e da CA. O certificado do broker vence antes e pode ser renovado no servidor sem tocar nos dispositivos, desde que a CA seja a mesma.
O que este artigo não cobre
- Certificado de cliente (TLS mútuo). Dá a cada ESP32 uma identidade própria, em vez de uma senha compartilhada. No Mosquitto são as opções require_certificate e use_identity_as_username; no ESP32, setCertificate() e setPrivateKey(). Aumenta o trabalho de emitir e revogar, e vale o custo quando houver muitos dispositivos.
- Permissões por tópico. TLS prova quem conecta, mas não limita o que cada um publica. Isso é ACL do broker.
- Proteção da chave no dispositivo. A CA é pública, mas a senha do MQTT gravada no firmware não é. Criptografia da flash e boot seguro do ESP32 entram nesse ponto, e merecem um artigo próprio.
Nota de honestidade
O código deste artigo segue a API do WiFiClientSecure e do PubSubClient do núcleo Arduino-ESP32, e os comandos do OpenSSL e do Mosquitto são os padrões. Mesmo assim, ele não foi compilado nem testado numa placa neste momento. Antes de levar a campo, compile no seu ambiente, rode os três testes negativos e confira o heap com a sua aplicação completa.
Para publicar dados depois de ter a conexão segura, o ponto de partida é o artigo de ESP32 publicando dados no Mosquitto. Se a rede cair, o buffer local guarda as leituras até a conexão voltar.
