TLS, Transport Layer Security

Those who want to connect to a server often require having the possibility to verify it is the desired server and not an impersonator. Those who offer a service might require some tool to authenticate the identity of incoming connections, that is, to determine those really belong to authorized users.

TLS(Transport Layer Security) allows mutually authenticating two parties at a conversation, and keeping its integrity. Authentication is done by means of X.509 certificates, and after that, keys are derived that allow transferred information to be encrypted. This means that whatever is sent can’t be observed with a network sniffer, and the only way to impersonate one of those entities is by having its certificate and the private key from which it’s been derived. TLS is a complex authentication scheme, requiring some certifying authority who generates keys and certificates used in this process. Due to it being a follow up to SSL, it is common to find references to this name.

The simplest form of operation, and maybe the most used, is server authentication; this happens for example when our web browser requests a page in the Internet using HTTPS (HTTP Secure). Before proceeding with the HTTP session, a handshake takes place that allows for validating the authenticity and establishing a secure connection, in the sense that the other end is identified and information integrity is protected. Web browsers know several certification authorities (have their certificates) and can validate a site by checking the signature in their credentials.

The other form of operation takes place in many IoT networks, when a device connects to “the cloud”. This time not only the server certificate is validated, the device certificate is validated too, that is there is a mutual authentication.

The details of this validation process depend on the method both parties agree to use, though roughly it consists in sending some information encrypted with the receiver’s public key, the receiver can only be able to decrypt and answer if he has the corresponding private key.

Even though authentication is a slow process involving Public Key Cryptography operations, the data transfer phase uses symmetric key encryption based on keys derived from the former process.

As we suggested at the beginning of this article, the security in this scheme resides on the impossibility of impersonating the Certification Authority, whose private key must be safely stored and protected from prying eyes.

MQTT sobre TLS-PSK con ESP32 y Mongoose-OS

Este artículo es una extensión de este otro; te recomiendo que lo veas primero.

Para mayor información sobre el cliente MQTT y la operación sin TLS, existe este artículo. Para más información sobre TLS, éste.

TLS-PSK

Existe una solución un poco más simple a la que vimos en el primer artículo; consiste en utilizar, en vez de certificados, una clave pre-compartida entre el broker y el dispositivo que se conecta.

El proceso de conexión es similar al empleado con certificados, sólo que en vez de validarse éstos y luego generarse una clave derivada, se utiliza como punto de partida la clave pre-compartida. Según el esquema utilizado, se evitan las costosas operaciones de clave pública/privada (Public Key Cryptography), y la logística de manejo de claves suele ser más simple, además del hecho de no requerir una CA (Certification Authority).

Si bien existen al momento tres formas de operar, una de las cuales permite validar al broker mediante certificados y al dispositivo mediante clave pre-compartida (PSK), sólo hemos probado la forma simple.

Configuración

Configuramos el ESP32 con Mongoose-OS para operar mediante TLS-PSK.

libs:
  - origin: https://github.com/mongoose-os-libs/mqtt  # incorpora el cliente MQTT
config_schema:
  - ["mqtt.enable", true]             # Habilita el cliente MQTT
  - ["mqtt.server", "address:port"]   # Dirección IP del broker a utilizar
  - ["mqtt.ssl_psk_identity", "bob"]  # identidad para la clave elegida
  - ["mqtt.ssl_psk_key", "000000000000000000000000deadbeef"] # clave AES-128 (ó 256)
  - ["mqtt.ssl_cipher_suites", "TLS-PSK-WITH-AES-128-CCM-8:TLS-PSK-WITH-AES-128-GCM-SHA256:TLS-ECDHE-PSK-WITH-AES-128-CBC-SHA256:TLS-PSK-WITH-AES-128-CBC-SHA"] # claves utilizables

El puerto comúnmente utilizado es 8883. El broker puede además solicitar que utilicemos nombre de usuario y password, aunque lo más común es configurarlo para aprovechar la identidad que se envía, como hicimos para estas pruebas (ejemplo más abajo).

Respecto a las cipher suites, se trata de indicar un conjunto de esquemas criptográficos que el sistema soporta y entre ellos debemos acertar a elegir uno que esté soportado por el broker. Este parámetro es obligatorio, sin él el dispositivo no anuncia ningún esquema criptográfico compatible con TLS-PSK y la conexión TLS no se establece. Lo habitual es acordarlo con el administrador del broker.

Al iniciar la conexión TLS, el dispositivo incluye los esquemas soportados en el mensaje ClientHello; el broker elige uno compatible y lo indica en el mensaje ServerHello. Dado que esta lista la obtuvimos analizando el código fuente, decidimos publicarla aquí para mayor utilidad. En nuestro caso en particular sólo uno de los esquemas coincidió con los disponibles en el broker: TLS-PSK-WITH-AES-128-CCM-8. El handshake de inicialización conteniendo estos mensajes puede observarse en la captura Wireshark que figura más abajo.

Por último, la clave pre-compartida (PSK, parámetro ssl_psk_key) debe tener una longitud de 128-bits (16-bytes) para sets basados en AES-128 y de 256-bits (32-bytes) para sets basados en AES-256.

Operación

Al iniciar el procesador, observaremos en el log si todo funciona como debe, o los errores que se hayan producido. En este último caso, resulta bastante difícil determinar las causas sin un sniffer dado que las pistas de ambos lados no suelen ser muy precisas. Tanto el handshake como la operación MQTT dentro de TLS pueden observarse en un sniffer, ingresando dicha clave en el mismo (por ejemplo Wireshark, ejemplo más abajo).

[Mar 31 17:28:32.349] mgos_mqtt_conn.c:435    MQTT0 connecting to 192.168.5.3:8883
[Mar 31 17:28:32.368] mongoose.c:4906         0x3ffc76ac ciphersuite: TLS-PSK-WITH-AES-128-CBC-SHA
[Mar 31 17:28:32.398] mgos_mqtt_conn.c:188    MQTT0 TCP connect ok (0)
[Mar 31 17:28:32.411] mgos_mqtt_conn.c:235    MQTT0 CONNACK 0
[Mar 31 17:28:32.420] init.js:34              MQTT connected
[Mar 31 17:28:32.434] init.js:26              Published:OK topic:/this/test/esp32_807A98 msg:CONNECTED!

Brokers: Mosquitto

Detallamos, a modo instructivo y como ayuda rápida, la configuración mínima necesaria para operar en Mosquitto. El path está en formato GNU/Linux y se espera que pongamos nuestro archivo con las claves allí.

listener 8883 192.168.5.1
log_dest syslog
use_identity_as_username true # evitamos que requiera usuario en el header MQTT
psk_file /etc/mosquitto/pskfile
psk_hint cualquiera           # cualquier nombre

El parámetro psk_hint, en el caso que el cliente se conecte a varios lugares, le da un indicio (hint) de qué identidad utilizar para identificarse. No tiene utilidad en nuestro caso.

El archivo pskfile, por su parte, debe contener las identidades y claves pre-compartidas; por ejemplo, para el usuario bob:

bob:000000000000000000000000deadbeef

Ejemplo

El código de ejemplo se encuentra en Github.

Sniffers: Wireshark

En todo sniffer, veremos el tráfico TLS pero no lo que éste transporta

Captura simple

En Wireshark, poder decodificar el tráfico TLS-PSK es tan simple como ingresar la clave compartida en Edit->Preferences->Protocols->TLS->Pre-Shared-Key.

Ingresamos la clave pre-compartida

El sniffer es entonces capaz de descifrar el contenido y nos permite observar el tráfico MQTT.

Captura descifrada

MQTT sobre TLS con ESP32 y Mongoose-OS

Mongoose-OS incorpora un cliente MQTT con soporte TLS. Esto nos permite por un lado autenticar al servidor, y por otro que la infraestructura del servidor pueda autenticar la identidad de esas conexiones, es decir, que realmente sean usuarios autorizados.

Para mayor información sobre el cliente MQTT y la operación sin TLS, existe este artículo. Para más información sobre TLS, éste.

Configuración

Configuramos el ESP32 con Mongoose-OS para tener un cliente MQTT y conectarse como cliente a una red WiFi. Dado que debemos conectarnos a un servidor, ésta nos resulta la forma tal vez más rápida y simple de realizar las pruebas y explicar la operación. La configuración puede realizarse manualmente mediante RPC, en un archivo de configuración JSON; o definirla en el archivo YAML que describe el proyecto. Para las pruebas elegimos esta última opción.

libs:
  - origin: https://github.com/mongoose-os-libs/mqtt  # incorpora el cliente MQTT
config_schema:
  - ["mqtt.enable", true]            # Habilita el cliente MQTT
  - ["mqtt.server", "address:port"]  # Dirección IP del broker a utilizar
  - ["mqtt.ssl_ca_cert", "ca.crt"]   # Certificado del broker, requerido
  - ["mqtt.ssl_cert", "sandboxclient.crt"]  # nuestro certificado, para autenticación mutua
  - ["mqtt.ssl_key", "sandboxclient.key"]   # nuestra clave, para autenticación mutua

El puerto comúnmente utilizado es 8883. El broker puede además solicitar que utilicemos nombre de usuario y password, los detalles de la configuración completa los podemos encontrar en la página de Mongoose-OS. Finalmente, quien decide qué tipo de autenticación (simple o mutua) utilizamos es el broker, aunque nosotros debemos proveer el certificado cuando es requerido.

Operación

Antes de compilar y ejecutar, es conveniente tener un cliente conectado al broker para poder observar el mensaje enviado al momento de conexión. Hemos utilizado un broker instalado en nuestro lab, y nos conectamos a él sin autenticación, por lo que lo mostrado en el artículo sobre MQTT vale para éste también

Luego de compilado el código (mos build) y grabado el microcontrolador (mos flash) mediante mos tool, observaremos en el log si todo funciona como debe, o los errores que se hayan producido. Recordemos que debemos configurar las credenciales para conectarnos por WiFi a nuestra red (SSID y clave) y la dirección y port del broker MQTT que vayamos a utilizar. Al final de la nota comentamos cómo utilizar Mosquitto.

Autenticación simple

De esta forma autenticamos al servidor, es decir, podemos confiar en que es el que esperamos que sea, pero el servidor no tiene idea de quién podemos llegar a ser nosotros.

Es la configuración más simple y no debemos tomar ningún recaudo más allá de informar el nombre del archivo que contiene el certificado de la CA almacenado en el directorio fs en el parámetro ssl_ca_cert, esto indica que se deberá validar al broker.

[Mar 31 17:21:18.360] mgos_mqtt_conn.c:435    MQTT0 connecting to 192.168.5.3:8883
[Mar 31 17:21:18.585] mongoose.c:4906         0x3ffca41c ciphersuite: TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256
[Mar 31 17:21:20.404] mgos_mqtt_conn.c:188    MQTT0 TCP connect ok (0)
[Mar 31 17:21:20.414] mgos_mqtt_conn.c:235    MQTT0 CONNACK 0
[Mar 31 17:21:20.423] init.js:34              MQTT connected
[Mar 31 17:21:20.437] init.js:26              Published:OK topic:/this/test/esp32_807A98 msg:CONNECTED!

Autenticación mutua

De esta forma autenticamos tanto al servidor como al cliente, es decir, ahora el servidor sabe que deberíamos ser quien nuestro certificado dice que somos. Este doble proceso requiere bastante procesamiento y lo notaremos en el tiempo de establecimiento de la conexión. En esta configuración deberemos proveer nuestro certificado y clave en el directorio fs, además de configurar los parámetros ssl_cert y ssl_key; caso contrario el broker puede que nos dé una pista en el log, como lo hace Mosquitto:

Mar 31 17:30:52 Server mosquitto[2694]: OpenSSL Error: error:140890C7:SSL routines:ssl3_get_client_certificate:peer did not return a certificate 

Brokers: Mosquitto

Detallamos, a modo instructivo y como ayuda rápida, las configuraciones mínimas necesarias para operar en Mosquitto. Los paths están en formato GNU/Linux y se espera que pongamos nuestros certificados allí.

Autenticación simple (del broker)

listener 8883 192.168.5.1
log_dest syslog
cafile /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key

Autenticación mutua

listener 8883 192.168.5.1
log_dest syslog
cafile /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
require_certificate true

Ejemplo

El código de ejemplo se encuentra en Github, aquí y aquí. Vamos a necesitar además las credenciales del servidor, dejé unas aquí para simplificar la operatoria.

TLS, seguridad en la capa de transporte

Quienes desean conectarse a un servidor suelen requerir la posibilidad de verificar que se trata del servidor deseado y no de impostor. Quienes dan un servicio pueden requerir una herramienta para autenticar la identidad de las conexiones, es decir, que realmente sean usuarios autorizados.

TLS(Transport Layer Security) permite autenticar mutuamente dos entidades en una conversación y mantener la integridad de la misma. La autenticación se realiza por certificados X.509, y luego de realizada, se derivan claves que permiten encriptar la información que viaja. Esto significa que lo que se envía no puede ser observado con un sniffer y la única forma de impersonar a una entidad es mediante la posesión del certificado y la clave privada de la que se deriva. Se trata de un esquema de autenticación complejo, con un ente certificador, quien genera las claves y certificados que se utilizan en el proceso. Dado que TLS se deriva de SSL, el esquema original (Secure Sockets Layer), es común que la nomenclatura refiera a este nombre.

La operación más simple y tal vez la más utilizada es la autenticación del servidor, operación que sucede cuando por ejemplo nuestro navegador solicita en Internet una página mediante HTTPS (HTTP Secure). Antes de proceder a la sesión HTTP, se realiza el handshake que permite la validación de autenticidad y el establecimiento de la conexión segura (“segura” en el sentido de que se identifica a la contraparte y se cuida la integridad de la información que viaja). El navegador conoce una serie de entes certificadores (tiene sus certificados) y puede validar mediante la firma al sitio solicitado cuando éste “le presenta sus credenciales”.

La otra operación posible es realizada en muchas redes IoT, cuando un dispositivo se conecta “a la nube”. En esta oportunidad no sólo se valida el certificado del servidor sino que éste a su vez valida el certificado del dispositivo, es decir, se establece una doble validación.

El proceso de validación en detalle depende del método acordado entre las contrapartes, aunque a grandes rasgos consiste en enviar una cierta información basada en la encripción con la clave pública del receptor, quien sólo puede descifrarla y así contestar si posee la clave privada correspondiente.

Si bien la autenticación es un proceso lento que involucra clave pública/privada (Public Key Cryptography), la comunicación transcurre encriptada por una clave simétrica derivada.

Como insinuamos al inicio, toda la seguridad del esquema radica en la imposibilidad de impersonar al ente certificador (Certification Authority), cuya clave privada es celosamente guardada y protegida.

Timer expired, o eso creo…

A menudo disponemos de varias formas de realizar una misma tarea. A veces la elección es por gusto o simpatía y otras el contexto suele dictar algunas normas que limitan las elecciones seguras.

En el caso de los timers por software, mi elección personal suele ser emplear un viejo esquema de timers individuales que luego de visitar assembler de varios micros ha terminado en C. Otras veces, dado que dispongo de 32-bits, por algún motivo u otro me inclino a simplemente chequear el valor de un contador. En ambos casos, tanto los timers individuales como el contador suelen ser decrementados e incrementado (respectivamente) por un interrupt handler.

Esto requiere que, por un lado, (en C) declaremos la variable de interés como volatile para que el compilador sepa que aún dentro de una función esta variable puede cambiar su valor. Caso contrario, loops de espera serán eliminados por el optimizador.

extern volatile uint32_t tickcounter;

Por otra parte, y por la misma razón, es necesario que el acceso a dicha variable sea atómico, es decir, ocurra en una instrucción del microprocesador o microcontrolador (o una secuencia no interrumpible de éstas). Caso contrario, podría aceptarse una interrupción en medio de la cadena de instrucciones que lee los bytes individuales que componen dicha variable y si dicha interrupción altera su contenido, obtendríamos un valor equivocado. Esto ocurre por ejemplo si utilizamos timers/contadores de 32-bits en la mayoría de los micros de 8-bits; ahondo en este tema en otro post alegórico.

Una vez resueltos estos temas, nos queda el problema de la finitud. No sólo de nuestra existencia, me refiero a la cantidad de bits que dan la longitud de los enteros que forman los timers.

El esquema de timers que les comenté es muy simple: cada timer es decrementado si su valor es distinto de cero, por lo que su uso se remite a escribir un valor y revisar frecuentemente si llegó a cero. Existirá obviamente un jitter dado por el tiempo que transcurre entre la escritura y el primer decremento en el interrupt handler, y luego desde que el timer llega a cero hasta que la función interesada lo observa.

El observar el valor de un contador, por el contrario, presenta algunos inconvenientes cuya solución motiva este post. Por problemas de atomicidad podemos vernos forzados a emplear una variable más chica y nos vamos a encontrar con que el contador desborda en un tiempo corto.

¿Qué ocurre cuando el contador desborda? Bueno, puede que nada si tomamos las precauciones necesarias.

Todo funciona perfectamente y pasa todos los tests si el contador no desborda entre chequeos, es decir, si el contador es lo suficientemente “largo” como para soportar todo el tiempo de vida de la aplicación. Por ejemplo, un contador de segundos de 32-bits tarda unos 136 años en desbordar. Sin embargo, un contador de milisegundos de 16-bits desborda una vez cada poco más de un minuto (65536 milisegundos).

Si el tiempo entre chequeos de dicho contador es mayor, existe la posibilidad de que el contador desborde más de una vez entre que lo chequeamos una y otra vez. En un caso como éste, deberemos valernos de otro recurso dado que, así solo, esto no nos permite controlar el tiempo.

Si en cambio podemos asegurar que el contador a lo sumo sólo desbordará una vez entre chequeos, deberemos escribir correctamente el código para que nuestra comparación sobreviva al desborde del contador. Caso contrario, como dijimos, todo funcionará bien pero “a veces” (cada vez que el tiempo deseado involucre un desborde intermedio), ocurrirán “cosas raras” con la operación del timer.

A los fines prácticos consideremos el siguiente esquema de variables, donde usamos mayúsculas violando tal vez algunos coding styles pero lo hacemos para resaltar el contador:

extern volatile uint32_t MS_TIMER;
uint32_t interval = 5000; // milisegundos
uint32_t last_time;   

Las opciones mostradas en la siguiente porción de código funcionan correctamente, dado que la diferencia sobrevive al overflow del contador y es comparada correctamente con el valor deseado:

	last_time = MS_TIMER;

	if(MS_TIMER - last_time > interval)
	    printf("Timer expired\n");

	if(MS_TIMER - interval > last_time)
	    printf("Timer expired\n");

Por el contrario, esto no funciona correctamente

uint32_t deadline;

	/* no debo hacer esto
	deadline = MS_TIMER + interval;
	if(MS_TIMER > deadline)
	    printf("Timer expired\n");
	*/

Si, por ejemplo, MS_TIMER tiene el valor 0xF8000000 e interval es 0x10000000, entonces deadline será igual a 0x08000000 y MS_TIMER es claramente mayor a deadline desde el inicio, lo cual ocasiona que el timer expire inmediatamente (no es lo que queremos…).

Una solución de compromiso para utilizar un esquema como el anterior requiere emplear enteros con signo y funciona para intervalos de hasta MAXINT32 unidades:

int32_t deadline;
int32_t interval = 5000; // milisegundos

	deadline = MS_TIMER + interval;
	if((int32_t)(MS_TIMER - deadline) > 0)
	    printf("Timer expired\n");

Otras formas más creativas pueden resultar en timers que nunca expiran cuando el contador supera MAXINT32, dado que se ve como un número negativo y es por ende menor que un tiempo solicitado (que es positivo); pero que sin embargo funcionan cuando se inicia (o reinicia) el equipo. En general las podemos agrupar en errores al elegir el largo de la variable (incluyendo al signo) y los vamos a dejar de lado.

Como pudimos observar, algunos problemas se manifiestan al referirse a tiempos MAXINT32 unidades temporales luego de iniciado el sistema. Por lo general este valor es 2^31, lo cual, para un contador de milisegundos son unos 25 días y para uno de segundos unos 68 años. Algunos errores en aplicaciones Linux y similares se manifestarán cuando reporten a tiempos iniciando a partir del año 2038 (el contador de segundos cuenta a partir del inicio del año 1970).

Si por el contrario utilizamos esquemas de 16 ú 8-bits…

En los ejemplos vistos, el uso de la comparación por ‘mayor’ nos garantiza que el intervalo va a ser mayor al solicitado. Si por ejemplo lo seteamos en este momento pidiendo un intervalo de un segundo y un milisegundo después el contador de segundos es incrementado, si utilizáramos comparación por ‘mayor o igual’ nuestro timer expiraría inmediatamente (bueno, en 1ms…). De modo similar, si lo solicitamos 1ms luego de la interrupción demorará 1,999s en expirar en vez de 1s. En un esquema donde la comparación y el incremento no son asíncronos, podemos evitar el jitter y emplear comparación por ‘igual o mayor’.

Sobre átomos y relojes

La particularidad de las interrupciones es que se caracterizan (como su nombre lo indica) por interrumpir al procesador en cualquier momento, de forma indistinta, aunque a veces pareciera que lo hacen de forma intencional sobre la tarea que más nos complica. La razón fundamental de su existencia es permitir que el micro pueda atender eventos que no es conveniente estar esperando o consultando, y/o que deben ser atendidos rápidamente y sin demoras.

Cuidadosamente utilizadas, son un poderoso aliado, y hasta es posible armar esquemas de tareas múltiples asignando a cada tarea una interrupción periódica independiente.

Su uso indiscriminado puede generar más inconvenientes que beneficios; particularmente no es posible compartir variables o subrutinas sin tomar las debidas precauciones de accesibilidad, atomicidad y reentrabilidad.

Una interrupción puede ocurrir en cualquier momento, y si ocurre en medio de la actualización de una variable multi-byte puede causar (y de hecho lo hace) estragos a las demás tareas, que quedan con valores “parcialmente alterados”, que en el mundo real son valores incorrectos, y probablemente de peligrosa incoherencia para el software. Un ejemplo típico y frecuentemente olvidado, son las variables de temporización, o fecha y hora. El programa principal se pone alegremente a descomponerlas byte a byte, de forma de mostrar su contenido en el display, sin pensar que, si son actualizadas por interrupciones, éstas no tienen por qué ocurrir antes o después de nuestro acceso y no durante el mismo. Si se muestran los valores en un display no hay mayor problema, al cabo de un segundo volveremos a la normalidad, pero si esto va a un log o informe vamos a tener que dar muchas explicaciones, como en el ejemplo siguiente, en el que aparece un registro con casi un año de diferencia

Utilicé alguna vez un entorno orientado a microcontroladores que “para subsanar este inconveniente”, incorporaba el concepto de ‘variables compartidas’ (shared variables). Ante un acceso o modificación de una variable así definida, se produce una inhibición de las interrupciones durante el transcurso de la misma. El problema que encuentro en este tipo de enfoques es que esto es malo para la didáctica como los pull-ups y diodos de protección en los GPIOs (como ya he comentado en otro artículo), el usuario no entiende qué pasa, no sabe, no aprende. Además, accesos reiterados a este tipo de variables producen inhibiciones reiteradas en las interrupciones, lo que se traduce como variaciones en la latencia de atención de éstas.

Si bien en una variable multi-byte como la del ejemplo esto resulta más que evidente, lo mismo ocurre en una variable de 32-bits o de 16-bits en micros de 8-bits que no disponen de instrucciones para acceder a variables de 32-bits (la gran mayoría) o 16-bits. La atomicidad, es decir, la propiedad de acceder a la variable como una unidad indivisible, se pierde, y cuando los accesos a estas variables son compartidos por tareas asíncronas existe la posibilidad de que se superpongan y entonces ocurra lo que hemos visto.

Si el entorno para desarrollar en C que utilizamos incorpora signal.h, deberíamos encontrar allí la definición del tipo sig_atomic_t, que identifica un tamaño de variable que puede ser accedido de forma atómica. Caso contrario, y como recomendación en estos entornos, el usuario debe conocer el micro con el que opera y obrar en consecuencia.

En entornos multiprocesados aparecen además otros inconvenientes, los cuales no abordaremos aquí.

Corolario

Una interrupción puede ocurrir en cualquier momento, siempre que esté habilitada. La probabilidad de que suceda justo en el instante en que se está produciendo la lectura de una variable cuyo acceso no es atómico es bastante baja, particularmente si ésta es breve y ocurre de forma no sincronizada con las interrupciones, y de manera no frecuente. Sin embargo, es mayor que cero, y por ende, en un número lo suficientemente alto de repeticiones, es de esperarse que ocurra. El dejar librado el correcto funcionamiento de un equipo a la función de distribución de Poisson no debería ser considerada buena práctica de diseño…

Este post contiene algunos extractos del libro “El Camino del Conejo“, con permiso del autor.

¿ Alguien ordenó un latch-up ?

(meditaciones sobre lo académicamente correcto en artículos y notas de aplicación)

Días atrás conversaba con un viejo amigo y maestro sobre esas cosas del diseño electrónico, y surgió el tema del latch-up. Mientras revisaba en mi cabeza un artículo que todavía no había escrito, recordé un ejemplo similar en otro ámbito que me trajo a colación este tema, y se materializa en este post.
Pensaba por ese entonces en lo malo que pueden resultar algunas conveniencias y facilidades a la hora de la didáctica… Por allá por el año 2004 estaba yo asistiendo a una conferencia para desarrolladores de una famosa empresa de microcontroladores “fáciles” (que son en realidad lo más difícil que se pueda inventar para alguien que ya conoce otros microcontroladores). El presentador estaba desarrollando sobre una nota de aplicación en la cual se veía un relé conectado directamente a un puerto de entrada/salida (GPIO) de un microcontrolador, a lo cual respetuosamente interrogué si, al menos, no convenía poner allí un diodo de flywheel (o “de protección”), a lo cual me respondió “todos nuestros microcontroladores tienen diodos de protección en los pines de entrada/salida”.

En primer lugar, soy de los que no utilizan los pull-ups incorporados en los micros excepto cuando alguna razón lo justifica. ¿Por qué? Porque intento que el diseño de hardware no dependa de detalles del fabricante (el día de mañana puedo portar el diseño a un microcontrolador “difícil” que no tenga estas “facilidades”) ni del firmware, y ahorrar esas décimas de centavo (despreciando el espacio en PCB) no me justifica el estar pendiente de recordar habilitar el pull-up o encontrarme con efectos no deseados hasta que la ejecución del código llega a ese punto. Eso sin contar que trabajando en muy bajo consumo esos pull-ups internos son impredecibles y hasta suelen ser contraproducentes. En segundo lugar, para quien no dispone del log de diseño (si existe), se pierde la razón “académica” por la cual ese resistor debe existir allí, todo lo que eso dice sobre el resto del circuito, lo cual me parece muy importante en una nota de aplicación, que es un documento de ingeniería.

Volviendo al relé controlado por un GPIO, digamos que esto propicia que un aficionado probablemente aprenda técnicas conflictivas que tal vez replique en otros ámbitos cuando progrese (me siento tentado a recordar el efecto de enseñar a programar a los jóvenes en los 80’s usando BASIC solamente).

Primer aspecto: la corriente que circula por el relé. Los GPIOs suelen presentar una estructura MOS del tipo push-pull, es decir, un MOSFET de canal P provee la corriente desde el positivo mientras que un MOSFET de canal N recibe la corriente que retorna hacia masa. Dichos MOSFETs suelen ser de áreas diferentes, siendo el de canal N de mayor capacidad de corriente, y en muchos casos tal vez insuficiente para operar un relé. Según cómo esté conectado el relé, puede operar correctamente porque el GPIO no eleva demasiado la tensión de salida pues el MOSFET de canal N es capaz de manejar esa corriente; u operar erráticamente porque el GPIO disminuye demasiado la tensión de salida pues el MOSFET de canal P es incapaz de manejar esa corriente. La segunda parte de esto es también, según cómo esté conectado el relé, que la corriente de éste circulará por el pin de GND o por el pin de VDD, es decir, entrará por el pin positivo desde la fuente o volverá a ésta por el pin de masa, creando, entre otras cosas, una circulación por caminos no deseados que puede afectar el funcionamiento en los transitorios de encendido y apagado del relé, por un lado por inductancias parásitas en las pistas, y por otro dado que cuando las corrientes van por un camino y vuelven por otro, se forma un lazo de corriente, una antena de cuadro, que irradia en todas direcciones con intensidad proporcional al área del lazo. La corriente por el pin de masa puede afectar además las mediciones de un conversor analógico-digital incluido en el chip (y la corriente por el pin de alimentación también si el microcontrolador economiza el pin de alimentación de la sección analógica, VDDA).

Segundo aspecto: la corriente que debería dejar de circular por el relé cuando éste se abre; pero dado que es un inductor, Faraday y Lenz conspiran contra nosotros: V = -L di/dt.

Cuando se desea abrir el relé, se debe poner el GPIO en estado de alta impedancia, idealmente circuito abierto, o al menos el estado opuesto al usado para activarlo. Supongamos que lo activamos conectándolo a masa, ahora debemos idealmente configurar un open-drain o al menos conectarlo a positivo. Al ser la bobina del relé un inductor, la corriente no puede cesar instantáneamente sino que intentará seguir circulando por ésta; dependiendo de qué tan difícil le resulte al inductor “mantener la corriente constante” (di/dt), aparecerá sobre sus bornes una tensión de la polaridad necesaria para hacer circular esa corriente que además es proporcional a la inductancia. Dicha polaridad hace que la tensión en el pin sea superior en este caso a la de alimentación del microcontrolador, tanto como sea necesario para que circule esa corriente. ¿Por dónde circulará esa corriente? Idealmente por un diodo que se denomina “de fly-wheel”, traducido en ocasiones como “rueda libre”, que se conecta con la polaridad apropiada en paralelo con la bobina del relé. En su ausencia por donde la alta tensión inducida encuentre el camino; en este caso en particular por el diodo de protección del GPIO y volviendo hasta el relé por la traza de alimentación que los une (en sentido inverso, generando una subida de tensión en la alimentación del micro), si colocamos el pin en alta impedancia. Si en cambio ponemos el pin en estado alto, deberá circular por el MOSFET de canal P en sentido inverso, probablemente por el diodo intrínseco que suele formarse en la estructura cristalina, y que suele oficiar de diodo de protección, o por un diodo de protección a tal efecto, lo que presente menos impedancia; suguiendo de allí igual camino al anterior.

Asumamos que los diodos de protección están diseñados para soportar la corriente que circula por la bobina, y que el resto del circuito tolera dichos efectos sin mayores complicaciones. ¿Qué sucede si replicamos este diseño en un microcontrolador que no tiene diodos de protección? (Dichos diodos muchas veces no son deseables, pero este artículo ya se haría muy largo…)

Bien, como dijimos, “por donde la alta tensión inducida encuentre el camino”. Algunas veces el camino se encuentra a través de algún periférico ocasionando comportamientos erráticos, otras ocurre algo mucho más riesgoso.

En las estructuras de semiconductor de los chips, se forman estructuras parásitas PNPN, lo cual es un tiristor no deseado. Este tiristor se puede disparar por corriente de disparo si algo logra inyectar una corriente suficiente por el bloque que de acuerdo a la polaridad oficie como “compuerta”, o por sobretensión, si aparece una tensión suficiente entre extremos de la estructura que supera su tensión de ruptura. El tiristor parásito comienza entonces a conducir y no deja de hacerlo hasta que se retira la alimentación, siendo lo más probable que se funda la estructura cristalina al no poder disipar el exceso de calor, formando un cortocircuito. Latch-up.

Por esta razón, suele ser mucho más didáctico controlar el relé con un transistor y colocar el correspondiente diodo de fly-wheel, llevando la corriente del relé lejos del conversor A/D. Caso contrario, un comentario indicando los efectos que podríamos tener y los componentes que eliminamos gracias a las bondades de esta facilidad, tal vez logren generar la curiosidad necesaria en el lector para investigar.

Si bien las nuevas tecnologías de fabricación minimizan la posibilidad de latch-up, siempre es conveniente colocar resistores en serie con los pines que puedan estar expuestos a tensiones más elevadas que la de alimentación, a fin de limitar la posible corriente de disparo y a la vez facilitar la extinción de la corriente por el tiristor o al menos limitarla para evitar la destrucción de la estructura cristalina.

Conectándonos a HiveMQ Cloud Basic con Mongoose-OS

HiveMQ Cloud es un servicio de broker MQTT basado en HiveMQ, un excelente broker MQTT desarrollado por la empresa del mismo nombre. Es posible utilizar HiveMQ ejecutándolo en nuestros servidores, o podemos aprovechar la infraestructura de HiveMQ Cloud y pagar un plan de acuerdo a nuestras necesidades.

HiveMQ Cloud Basic es gratuito para hasta 100 conexiones concurrentes (al momento de escribir este artículo), lo cual es muy útil para nosotros los desarrolladores, a fin de poder realizar rápidas pruebas de concepto en un ambiente estable. Incluso también es posible comenzar con la opción gratuita y luego migrar al servicio pago una vez que el negocio comienza a ganar momento.

El broker cumple con el standard MQTT 5, y contiene todo lo que se espera de él, incluyendo QoS2, mensajes “de última voluntad” y retención de mensajes. Las conexiones utilizan TLS en modalidad de autenticación del servidor, no se utilizan certificados para los clientes, las credenciales válidas son un nombre de usuario y un password creados en la consola web.

Para más información, podemos leer la Quick Start Guide.

Configuración

En mos.yml, agregamos el nombre del broker y el puerto, el certificado de la CA para TLS, y la pareja usuario/password. El nombre del broker lo obtenemos al ver los detalles de nuestro cluster en la consola de HiveMQ. La pareja de credenciales usuario/password la configuramos en dicha consola, en la sección de control de acceso (Access Management).

config_schema:
  - ["mqtt.enable", true]
  - ["mqtt.server", "blablabla.hivemq.cloud:8883"]
  - ["mqtt.user", "username"]                        
  - ["mqtt.pass", "password"]                       
  - ["mqtt.ssl_ca_cert", "trustid-x3-root.pem"]      

Para mayor información sobre MQTT sobre TLS te recomiendo este otro artículo.

Ejemplo

En Github disponemos de un ejemplo funcional. Es un simple script mJS que publica un pequeño mensaje cuando se establece la conexión con el broker.

El certificado de la CA lo hemos incluido entre los archivos de dicho ejemplo, en caso que tengamos algún problema con él, la documentación de HiveMQ Cloud nos explica cómo obtenerlo, pero al parecer debemos estar registrados para acceder porque es parte de la consola. Mongoose-OS requiere que se configure el certificado para saber que debe iniciar TLS y validar a su vez el certificado del broker.

Si todo sale bien, luego de compilar ejecutando mos build y grabar ejecutando mos flash veremos esto ejecutando mos console:

[May 21 14:57:38.806] mgos_mqtt_conn.c:468    MQTT0 connecting to numerolargo enhexa.s1.eu.hivemq.cloud:8883
[May 21 14:57:38.841] mgos_mongoose.c:66      New heap free LWM: 210876
[May 21 14:57:39.350] mg_ssl_if_mbedtls.c:30  0x3ffc8818 ciphersuite: TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256
[May 21 14:57:40.741] SW ECDH curve 3
[May 21 14:57:41.170] mgos_mongoose.c:66      New heap free LWM: 191588
[May 21 14:57:41.467] mgos_mqtt_conn.c:227    MQTT0 TCP connect ok (0)
[May 21 14:57:41.733] mgos_mqtt_conn.c:271    MQTT0 CONNACK 0
[May 21 14:57:41.742] init.js:34              MQTT connected
[May 21 14:57:41.756] init.js:26              Published:OK topic:/this/test/esp32_807A98 msg:CONNECTED!

Si disponemos de un cliente como mosquitto_sub client, o algún otro de nuestro agrado, podemos conectarnos al broker para ver que todo funcione bien. En nuestro caso, hemos utilizado CentOS 7 y el cliente mosquitto requiere que además se le especifique dónde hallar el certificado de la CA:

$ mosquitto_sub -h somelonghexnumber.s1.eu.hivemq.cloud -p 8883 -t "#" -u yourusername -P yourpassword --cafile pathto/isrgrootx1.pem -v
	/this/test/esp32_807A98 CONNECTED!

MQTT with the ESP32 and Mongoose-OS

Mongoose-OS has a built-in MQTT client and a simple mJS (a reduced JavaScript) interpreter. These tools allow us to quickly and easily connect to an MQTT broker and perform our proof-of-concept tests for more complex applications.

Configuration

We’ll configure the ESP32 running Mongoose-OS to use the MQTT client and connect to a WiFi network. Since we are going to connect to a server (the MQTT broker), this is perhaps the simplest and fastest way to run our tests. This action can be done manually using RPCs (Remote Procedural Calls), writing a JSON config file, or it can be defined in the YAML file that describes how the project will be built. We’ve chosen this last option for our tests.

libs:
  - origin: https://github.com/mongoose-os-libs/mqtt  # Include the MQTT client
config_schema:
  - ["mqtt.enable", true]            # Enable the MQTT client
  - ["mqtt.server", "address:port"]  # Broker IP address (and port)

The most common port for MQTT is 1883. The broker can also ask us to send a username and password, full configuration details are available at the corresponding Mongoose-OS doc page.

Operation

Before we build our project and execute our code, it is perhaps convenient to have an MQTT client connected to the broker, so we can see the message the device will send at connect time. We’ve used a broker we installed in a server in our lab:

$ mosquitto_sub -h mqtt.lab -p 1883 -t "#" -v

In addition (if daily traffic allows), it is also possible to use Eclipse’s open MQTT broker, in that case we should be a bit more specific in the topic we subscribe to, in order to try to receive only our messages

$ mosquitto_sub -h mqtt.eclipseprojects.io -p 1883 -t "/this/#" -v

After the code is compiled and linked (mos build) and the microcontroller’s flash is programmed (mos flash) using mos tool, we’ll watch the log and check if everything is going on as it should, or catch any possible errors. Remember we need to configure the proper access credentials to connect to our WiFi network of choice (SSID and password), and the address and port for the MQTT broker we are going to use.

[Feb 11 15:19:42.353] mgos_mqtt_conn.c:435    MQTT0 connecting to mqtt.sensors.lab:1883
[Feb 11 15:19:42.370] mgos_mqtt_conn.c:188    MQTT0 TCP connect ok (0)
[Feb 11 15:19:42.380] mgos_mqtt_conn.c:235    MQTT0 CONNACK 0
[Feb 11 15:19:42.385] mgos_mqtt_conn.c:168    MQTT0 sub /this/sub @ 1
[Feb 11 15:19:42.393] init.js:38              MQTT connected
[Feb 11 15:19:42.407] init.js:26              Published:OK topic:/this/pub/esp32_807A98 msg:CONNECTED!

At this time we’ll see that message in our MQTT client window:

$ mosquitto_sub -h mqtt.lab -p 1883 -t "#" -v
/this/pub/esp32_807A98 CONNECTED!

Then, we can send a message to our device by publishing to the topic to which it is subscribed:

$ mosquitto_pub -t /this/sub -h mqtt.lab -m "This message"

At this time we’ll see that message in our MQTT client window:

$ mosquitto_sub -h mqtt.lab -p 1883 -t "#" -v
/this/pub/esp32_807A98 CONNECTED!
/this/sub This message

and in our device’s log

[Feb 11 15:20:02.647] init.js:32              Got a msg: This message

Finally, we’ll power off our ESP32 and after a while (minutes) the broker will detect the disconnection; at that time we’ll see the last-will message in our MQTT client window

$ mosquitto_sub -h mqtt.lab -p 1883 -t "#" -v
/this/pub/esp32_807A98 CONNECTED!
/this/sub This message
/this/test lost

The mJS code

Whenever we want to publish a message, we’ll call MQTT.pub(). There’s no need to wait nor perform any handshakes, just invoke that method and see the results. Internally, the message is queued and will be sent as soon as possible; the value returned by the publishing method is an indicator of the result of the queuing action, not the actual transmission of the message.

This article has companion code, there we’ve written a simple function as a usage example:

let publish = function (topic, msg) {
    let ok = MQTT.pub(topic, msg, 1, false); // QoS = 1, do not retain
    Log.print(Log.INFO, 'Published:' + (ok ? 'OK' : 'FAIL') + ' topic:' + topic + ' msg:' +  msg);
    return ok;
};

let device_id = Cfg.get('device.id');
publish('/this/pub/'+device_id,'CONNECTED!');

Should we want our message to be retained, the fourth parameter for the MQTT.pub() method call must be true.

To receive messages, we subscribe to the desired topic invoking the MQTT.sub() method. Its second parameter is a handler for a callback function that will be run when we get a message. The following example can also be found in the companion code:

MQTT.sub('/this/sub', function(conn, topic, msg) {
    Log.print(Log.INFO, 'Got a msg: ' + msg);
}, null);

Last-will messages

If we want to configure a “last-will message”, that is, a message that will be published by the broker in our behalf when it detects we are no longer connected, we can do it this way:

  - ["mqtt.will_topic", "/this/test"]
  - ["mqtt.will_message", "lost"]

For some applications it is required that this message is also retained by the broker, so if other clients connect after we disconnected and before we reconnect, they can receive that message, which will in turn inform them that we are not available at that time.

  - ["mqtt.will_retain", true]

Retain, persistence

Message retention (be them last-will or common messages with the retain flag set) requires an MQTT broker with persistence capability, that is, with a database in which to store the latest messages that have been marked as persistent in every topic and send them to whoever client subscribes to that topic when it does so.

In our case, we’ve used Eclipse Mosquitto as our broker, but there are other available options.

When using Mosquitto, to configure persistence we need to add

persistence true

to the config file (if we search for it, we’ll find it). It is also possible that we have to add the name and path to the persistence database (paths in GNU/Linux environment form):

persistence_file mosquitto.db
persistence_location /var/lib/mosquitto/

but that will depend on our distro, it may already be configured as default. The user running the Mosquitto process has to have write permission on this database.

Example

The companion example code is available at Github.

MQTT con ESP32 y Mongoose-OS

Mongoose-OS incorpora un cliente MQTT y un simple intérprete mJS (una versión reducida de JavaScript). Con ellos podemos rápida y fácilmente conectarnos a un broker MQTT y realizar pruebas de concepto de aplicaciones más complejas.

Configuración

Configuramos el ESP32 con Mongoose-OS para tener un cliente MQTT y conectarse como cliente a una red WiFi. Dado que debemos conectarnos a un servidor, ésta nos resulta la forma tal vez más rápida y simple de realizar las pruebas y explicar la operación. La configuración puede realizarse manualmente mediante RPC, en un archivo de configuración JSON; o definirla en el archivo YAML que describe el proyecto. Para las pruebas elegimos esta última opción.

libs:
  - origin: https://github.com/mongoose-os-libs/mqtt  # incorpora el cliente MQTT
config_schema:
  - ["mqtt.enable", true]            # Habilita el cliente MQTT
  - ["mqtt.server", "address:port"]  # Dirección IP del broker a utilizar

El puerto comúnmente utilizado es 1883. El broker puede además solicitar que utilicemos nombre de usuario y password, los detalles de la configuración completa los podemos encontrar en la página de Mongoose-OS.

Operación

Antes de compilar y ejecutar, es conveniente tener un cliente conectado al broker para poder observar el mensaje enviado al momento de conexión. Hemos utilizado un broker instalado en nuestro lab:

$ mosquitto_sub -h mqtt.lab -p 1883 -t "#" -v

También (si el tráfico habitual lo permite) es posible usar el broker abierto de Eclipse , en cuyo caso deberemos limitar un poco el tópico a suscribirnos a fin de intentar recibir sólo nuestros mensajes

$ mosquitto_sub -h mqtt.eclipseprojects.io -p 1883 -t "/this/#" -v

Luego de compilado el código (mos build) y grabado el microcontrolador (mos flash) mediante mos tool, observaremos en el log si todo funciona como debe, o los errores que se hayan producido. Recordemos que debemos configurar las credenciales para conectarnos por WiFi a nuestra red (SSID y clave) y la dirección y port del broker MQTT que vayamos a utilizar.

[Feb 11 15:19:42.353] mgos_mqtt_conn.c:435    MQTT0 connecting to mqtt.sensors.lab:1883
[Feb 11 15:19:42.370] mgos_mqtt_conn.c:188    MQTT0 TCP connect ok (0)
[Feb 11 15:19:42.380] mgos_mqtt_conn.c:235    MQTT0 CONNACK 0
[Feb 11 15:19:42.385] mgos_mqtt_conn.c:168    MQTT0 sub /this/sub @ 1
[Feb 11 15:19:42.393] init.js:38              MQTT connected
[Feb 11 15:19:42.407] init.js:26              Published:OK topic:/this/pub/esp32_807A98 msg:CONNECTED!

En este momento observaremos el mensaje en nuestro cliente:

$ mosquitto_sub -h mqtt.lab -p 1883 -t "#" -v
/this/pub/esp32_807A98 CONNECTED!

A continuación, enviamos un mensaje a nuestro dispositivo publicando uno en el tópico al que éste se halla suscripto:

$ mosquitto_pub -t /this/sub -h mqtt.lab -m "This message"

En este momento observaremos el mensaje en nuestro cliente

$ mosquitto_sub -h mqtt.lab -p 1883 -t "#" -v
/this/pub/esp32_807A98 CONNECTED!
/this/sub This message

y en el log de nuestro dispositivo

[Feb 11 15:20:02.647] init.js:32              Got a msg: This message

Finalmente, apagamos nuestro ESP32 y al cabo de unos minutos el broker detectará la desconexión; en ese momento observaremos el mensaje “de última voluntad” en nuestro cliente

$ mosquitto_sub -h mqtt.lab -p 1883 -t "#" -v
/this/pub/esp32_807A98 CONNECTED!
/this/sub This message
/this/test lost

El código mJS

Cuando queremos publicar algo, invocamos el método MQTT.pub(). No debemos esperar ni hacer handshakes, sólo llamarlo y observar el resultado. Internamente el mensaje es puesto en una cola, de donde saldrá cuando sea posible. El valor devuelto indica que ha sido posible encolar el mensaje, no que éste ha llegado a destino. En el código que acompaña a este artículo creamos una función como ejemplo de uso:

let publish = function (topic, msg) {
    let ok = MQTT.pub(topic, msg, 1, false); // QoS = 1, do not retain
    Log.print(Log.INFO, 'Published:' + (ok ? 'OK' : 'FAIL') + ' topic:' + topic + ' msg:' +  msg);
    return ok;
};

let device_id = Cfg.get('device.id');
publish('/this/pub/'+device_id,'CONNECTED!');

Si queremos que el mensaje sea retenido, el cuarto parámetro de MQTT.pub() debe ser verdadero.

Para recibir mensajes, nos suscribimos al tópico deseado usando el método MQTT.sub(). El segundo parámetro es un handler a una función callback que se ejecutará cuando recibamos un mensaje. En el código que acompaña a este artículo mostramos un ejemplo de uso:

MQTT.sub('/this/sub', function(conn, topic, msg) {
    Log.print(Log.INFO, 'Got a msg: ' + msg);
}, null);

Last will, mensajes “de última voluntad”

Si deseamos configurar una “última voluntad”, es decir, un mensaje que el broker publicará por nosotros ante la detección de una desconexión, podemos hacerlo como se ve a continuación:

  - ["mqtt.will_topic", "/this/test"]
  - ["mqtt.will_message", "lost"]

En algunas aplicaciones es necesario que este mensaje sea retenido por el broker, de modo que otros clientes que se conecten luego de nuestra desconexión y antes de nuestra reconexión puedan recibir dicho mensaje, que por lo general tiene la misión de indicar que no estamos disponibles.

  - ["mqtt.will_retain", true]

Retain, persistencia

La retención de mensajes (sean “de última voluntad” o mensajes corrientes con el flag retain seteado) requiere de un broker MQTT con capacidad de persistencia (persistence), es decir, con una base de datos que almacene los últimos mensajes marcados como persistentes y los retransmita a quien se suscribe al tópico correspondiente al momento de iniciar la suscripción.

En nuestro caso hemos utilizado como broker Eclipse Mosquitto, pero hay otras opciones.

En el caso de Mosquitto, para configurar la persistencia debemos agregar

persistence true

en el archivo de configuración (si lo buscamos lo encontramos). También es posible que además debamos agregar el nombre y ubicación de la base de datos de persistencia (paths en formato ambiente GNU/Linux):

persistence_file mosquitto.db
persistence_location /var/lib/mosquitto/

pero eso depende de la distribución que utilicemos, por lo que puede que ya esté hecho por defecto. El usuario bajo el cual se ejecuta el proceso debe tener permisos de escritura a esta base de datos.

Ejemplo

El código de ejemplo se encuentra en Github.