Seguridad MQTT

Cifrado extremo a extremo del payload MQTT con AES-GCM

TLS protege los mensajes MQTT en tránsito, pero el broker sigue viendo texto plano. Aprende a añadir cifrado extremo a extremo del payload MQTT con AES-GCM en Python y a gestionar claves, nonces y topics con seguridad.

Ilustración del cifrado del payload MQTT con un sobre sellado que viaja a través de un broker desde un sensor hasta una aplicación
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

El cifrado del payload MQTT consiste en cifrar el propio cuerpo del mensaje, de modo que solo el publicador y los suscriptores previstos puedan leerlo, sin importar quién opere el broker. En esta guía aprenderás cuándo merece la pena añadir cifrado extremo a extremo además de TLS, cómo diseñar el formato del mensaje de forma segura y cómo implementarlo en Python con AES-GCM sobre una conexión real a un broker.

TLS frente al cifrado del payload MQTT

TLS cifra la conexión entre cada cliente y el broker. Es imprescindible y deberías usarlo siempre; consulta nuestra guía de configuración TLS en MQTT. Pero TLS termina en el broker. Dentro del broker, un mensaje está en texto plano: tiene que estarlo para que el broker pueda enrutarlo, guardarlo como retained message o encolarlo para una sesión sin conexión.

El cifrado del payload añade una segunda capa que viaja con el mensaje desde el publicador hasta el suscriptor.

Propiedad Solo TLS TLS + cifrado del payload
Protegido en la red Sí Sí
Legible por el operador del broker Sí No
Legible en mensajes retenidos o encolados en reposo Sí No
Nombres de topic ocultos No (visibles para el broker) No (siguen visibles para el broker)
El broker puede inspeccionar o decodificar los payloads Sí No
Gestión de claves necesaria Solo el certificado del broker Claves de aplicación en cada cliente

Fíjate en las dos filas con "No" que la gente suele pasar por alto. Los topics siguen siendo legibles, porque el broker los necesita para enrutar, así que nunca pongas secretos en los nombres de topic. Y cualquier función del broker que lea payloads deja de funcionar con mensajes cifrados. Por ejemplo, el explorador de topics en vivo, las reglas de alerta por umbral y la decodificación de Sparkplug B de ConnectMQ solo pueden mostrar o evaluar datos que sean capaces de analizar, así que los payloads cifrados aparecerán ahí como bytes opacos.

Cuándo tiene sentido el cifrado extremo a extremo

Añade cifrado del payload cuando:

  • La normativa o los contratos exigen que los proveedores de infraestructura no puedan leer los datos.
  • Los mensajes pasan por brokers, bridges o gateways compartidos o públicos que no controlas por completo.
  • Los mensajes retenidos o las sesiones encoladas contienen valores sensibles, como datos personales o códigos de acceso.
  • Quieres defensa en profundidad por si se filtran las credenciales de una parte del sistema.

Prescinde de él cuando necesites visibilidad en el broker (dashboards en el portal del broker, reglas de alerta sobre valores) y confíes en el operador, o cuando los dispositivos con recursos limitados no puedan permitirse el código extra y la gestión de claves. Muchos equipos cifran solo unos pocos topics sensibles, como .../commands/unlock, y dejan la telemetría en texto plano sobre TLS.

Elegir un algoritmo

Usa un algoritmo de cifrado autenticado. Cifra el payload y añade una etiqueta que detecta cualquier modificación. Dos buenas opciones:

  • AES-256-GCM: ampliamente disponible, acelerado por hardware en muchas CPU y en el ESP32 (a través de mbedTLS). Requiere un nonce único de 96 bits por mensaje con la misma clave.
  • XChaCha20-Poly1305 (libsodium crypto_aead_xchacha20poly1305_ietf): rápido en software, y su nonce de 192 bits puede generarse aleatoriamente sin riesgo realista de colisión.

No uses AES-CBC sin MAC, ningún tipo de modo ECB ni "cifrado" con XOR o Base64. Dan una falsa sensación de seguridad.

Diseñar el formato del mensaje cifrado

Un formato sencillo y robusto para AES-GCM es:

| version (1 byte) | nonce (12 bytes) | ciphertext + 16-byte tag |

Tres reglas de diseño importan más que el algoritmo:

  1. Nunca reutilices un nonce con la misma clave. Con AES-GCM, la reutilización puede exponer el texto plano y permitir falsificaciones. Un nonce aleatorio de 12 bytes es aceptable para volúmenes de mensajes moderados por clave; rota las claves periódicamente en los publicadores de gran volumen.
  2. Vincula el texto cifrado a su topic. Pasa el topic como datos adicionales autenticados (AAD). De lo contrario, un atacante con acceso al broker podría reproducir un mensaje cifrado de "unlock" en el topic de otro dispositivo.
  3. Incluye una marca de tiempo o un contador dentro del texto plano para que los suscriptores puedan rechazar mensajes antiguos reproducidos más tarde.

Ejemplo en Python: publicación y suscripción cifradas

El ejemplo usa paho-mqtt 2.x y el paquete cryptography. Instálalos primero:

pip install "paho-mqtt>=2.0" cryptography

Genera una clave una sola vez y compártela con el publicador y el suscriptor a través de tu almacén de secretos o de variables de entorno, no por MQTT:

python -c "import os,base64;print(base64.b64encode(os.urandom(32)).decode())"

Guarda lo siguiente como secure_mqtt.py:

import base64
import json
import os
import sys
import time

import paho.mqtt.client as mqtt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag

BROKER = "broker.connectmq.com"
PORT = 8883
TOPIC = "connectmq/demo/e2e-91c4/door/command"
VERSION = b"\x01"
MAX_AGE_SECONDS = 30

KEY = base64.b64decode(os.environ["MQTT_PAYLOAD_KEY"])
aead = AESGCM(KEY)


def encrypt(topic: str, data: dict) -> bytes:
    data["ts"] = int(time.time())
    plaintext = json.dumps(data).encode()
    nonce = os.urandom(12)
    ciphertext = aead.encrypt(nonce, plaintext, topic.encode())
    return VERSION + nonce + ciphertext


def decrypt(topic: str, blob: bytes) -> dict:
    if len(blob) < 1 + 12 + 16 or blob[:1] != VERSION:
        raise ValueError("unknown message format")
    nonce, ciphertext = blob[1:13], blob[13:]
    plaintext = aead.decrypt(nonce, ciphertext, topic.encode())
    data = json.loads(plaintext)
    if abs(time.time() - data["ts"]) > MAX_AGE_SECONDS:
        raise ValueError("stale or replayed message")
    return data


def on_connect(client, userdata, flags, reason_code, properties):
    print("Connected:", reason_code)
    if userdata == "sub":
        client.subscribe(TOPIC, qos=1)


def on_message(client, userdata, msg):
    try:
        print("Decrypted:", decrypt(msg.topic, msg.payload))
    except (InvalidTag, ValueError) as exc:
        print("Rejected message:", exc)


def make_client(role: str) -> mqtt.Client:
    client = mqtt.Client(
        mqtt.CallbackAPIVersion.VERSION2,
        client_id=f"e2e-{role}-91c4",
        userdata=role,
    )
    client.tls_set()  # TLS sigue protegiendo las credenciales y los metadatos
    # Broker privado de ConnectMQ:
    # client.username_pw_set("YOUR_USERNAME", "YOUR_PASSWORD")
    client.on_connect = on_connect
    client.on_message = on_message
    client.connect(BROKER, PORT, keepalive=60)
    return client


if __name__ == "__main__":
    role = sys.argv[1] if len(sys.argv) > 1 else "sub"
    client = make_client(role)
    if role == "pub":
        client.loop_start()
        time.sleep(1)
        payload = encrypt(TOPIC, {"action": "unlock", "door": "lab-2"})
        client.publish(TOPIC, payload, qos=1).wait_for_publish()
        print("Published", len(payload), "encrypted bytes")
        client.loop_stop()
        client.disconnect()
    else:
        client.loop_forever()

Ejecuta el suscriptor y el publicador en dos terminales con la misma clave:

export MQTT_PAYLOAD_KEY="paste-the-base64-key-here"
python secure_mqtt.py sub      # terminal 1
python secure_mqtt.py pub      # terminal 2

Si te suscribes al mismo topic con mosquitto_sub solo verás ruido binario, que es precisamente la idea. Cambia un byte de un payload, publícalo en otro topic o espera más de 30 segundos antes de descifrarlo, y el suscriptor lo rechazará. Como el ejemplo usa el servidor MQTT público gratuito, pensado para pruebas y prototipos y no para datos de producción, cualquiera podría ver el texto cifrado, pero nadie sin la clave puede leerlo ni falsificarlo.

Gestión de claves: la parte difícil

El código de cifrado es corto; la gestión de claves es donde los proyectos triunfan o fracasan.

  1. Acota las claves. Usa una clave por dispositivo o por grupo de dispositivos, no una clave para toda la flota. Una clave filtrada debe exponer lo menos posible.
  2. Guarda las claves de forma segura en los dispositivos. Usa flash cifrada o un elemento seguro en los microcontroladores, y un almacén de claves del sistema operativo o un gestor de secretos en los servidores. Nuestra guía para almacenar credenciales MQTT en dispositivos se aplica igualmente a las claves de cifrado.
  3. Incluye un ID de clave. Amplía la cabecera con un identificador de clave para que los suscriptores elijan la clave correcta y puedas rotar sin cortes.
  4. Rota las claves. Introduce una clave nueva, deja que los suscriptores acepten ambas, cambia los publicadores y después retira la clave antigua.
  5. Considera esquemas asimétricos para flujos de muchos a uno. Con las sealed boxes de libsodium, miles de dispositivos pueden cifrar con la clave pública de un backend, mientras que solo el backend tiene la clave privada.

Dispositivos embebidos

En un ESP32, mbedTLS (ya presente para TLS) ofrece mbedtls_gcm_crypt_and_tag y mbedtls_gcm_auth_decrypt, así que el cifrado del payload añade poco tamaño de código. Usa el generador de números aleatorios por hardware (esp_fill_random) para los nonces y asegúrate de que el Wi-Fi o el Bluetooth estén en marcha, o de que haya una fuente de entropía adecuada habilitada, para que la salida del RNG sea realmente aleatoria.

Errores de implementación habituales

Al revisar código de cifrado de payloads en proyectos IoT, aparecen una y otra vez los mismos errores:

  • Un nonce fijo o un contador que se reinicia al arrancar. Un dispositivo que pone su contador a cero tras cada ciclo de alimentación reutilizará nonces. Usa nonces aleatorios o guarda el contador en flash.
  • Ignorar los fallos de descifrado. Un suscriptor que registra un error InvalidTag y aun así procesa el mensaje no tiene ninguna protección. Trata cualquier fallo como un mensaje descartado.
  • Comprimir después de cifrar. El texto cifrado no se comprime, así que comprime primero el JSON si el tamaño importa.
  • Mezclar formatos sin un byte de versión. Cuando cambies de algoritmo o añadas un ID de clave, los dispositivos antiguos y nuevos deben poder distinguirse. El byte de versión del ejemplo existe exactamente por eso.
  • Poner la clave en el mismo repositorio que el código. Las claves van en un almacén de secretos, variables de entorno o almacenamiento seguro en el dispositivo, nunca en Git.

Prueba deliberadamente los casos negativos: publica un mensaje truncado, un mensaje con un bit cambiado y un mensaje antiguo reproducido, y confirma que cada uno se rechaza.

El cifrado no sustituye a la seguridad del broker

El cifrado del payload protege la confidencialidad y la integridad de los datos, pero no impide que alguien se conecte, inunde topics o se suscriba a metadatos. Sigues necesitando TLS, credenciales sólidas por dispositivo y protecciones en el broker. Un broker MQTT gestionado como ConnectMQ ofrece TLS, credenciales vinculadas a client ID, cuotas por plan, protección contra floods y bloqueo de IP tras varios logins fallidos, mientras que el cifrado extremo a extremo mantiene privados los propios datos. Juntos cubren la checklist de nuestras buenas prácticas de seguridad MQTT.

Empieza con ConnectMQ

Crea un broker gratuito en app.connectmq.com con el plan Serverless, sin tarjeta de crédito, y ejecuta el ejemplo anterior con tus propias credenciales en el puerto 8883. ConnectMQ se encarga de TLS, la autenticación y el escalado, mientras tu aplicación conserva las claves de los payloads.

Preguntas frecuentes

¿Tiene MQTT cifrado del payload integrado?

No. MQTT 3.1.1 y 5.0 no definen cifrado del payload; los payloads son bytes opacos. El cifrado lo aporta TLS en la conexión y, opcionalmente, tu aplicación sobre el payload.

¿Puedo cifrar también el nombre del topic?

No sin romper el enrutamiento, porque el broker compara las suscripciones con los nombres de topic. Mantén los topics libres de información sensible y usa ID de dispositivo opacos en lugar de nombres o direcciones.

¿El cifrado del payload aumenta el tamaño del mensaje?

Ligeramente. El formato AES-GCM anterior añade 29 bytes por mensaje (versión, nonce y etiqueta), más la sobrecarga de codificación si conviertes el resultado a Base64.

¿Debo seguir usando TLS si los payloads van cifrados?

Sí. TLS protege tus credenciales MQTT, los client ID, los nombres de topic y los metadatos del protocolo, y autentica al broker; nada de eso lo cubre el cifrado del payload.

Artículos relacionados

Cómo rotar credenciales MQTT sin tiempo de inactividad

Una estrategia de doble credencial paso a paso para rotar usuarios y contraseñas MQTT en toda una flota: prepara la nueva credencial, cambia con respaldo, verifica y solo entonces revoca la antigua.

8 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.