← Blog

· 17 min de lectura #rust#mqtt#iot#unikernel#open source#security

urMQTT v0.3.0: cifrado del disco, un arranque que rechaza una imagen modificada y lo que estropeamos nosotros mismos

Lo que pasó después del tercer texto sobre urMQTT: cifrado del disco en reposo con un coste medido, una prueba que cambia un byte del kernel y muestra que el cargador lo rechaza, un paquete de auditoría con 889 sitios unsafe justificados, los errores que encontramos nosotros mismos (y una afirmación que escribimos mal), y la versión v0.3.0, con la lista de lo que todavía no está demostrado, incluido un CI en rojo.

El tercer texto, urMQTT después del VPS, terminaba con una lista de "Lo que sigue": un broker de demostración público (D1), una segunda ronda sobre el flujo único lento, la ruta en texto plano frente a Mosquitto en el VPS y, "después de la versión", bare metal, IOMMU, Secure Boot y cifrado del disco. Este texto cuenta qué de todo eso se hizo, qué no, y qué encontramos por el camino. El cifrado del disco y una parte de Secure Boot sí entraron antes de la versión, porque estaban en el camino hacia la auditoría. La segunda ronda sobre el flujo único y la ruta en texto plano frente a Mosquitto no se hicieron.

La advertencia previa sigue siendo la misma: el proyecto está en fase pre-alfa, la versión apunta a VPS con KVM, y las cifras miden código, pruebas y experimentos concretos, no madurez en producción.

Cómo se vio a lo largo del tiempo

Desde el último texto (1 de noviembre, 20:41) hasta hoy (3 de noviembre, 02:43) la rama principal tiene 37 commits nuevos, 215 en total. Como antes, el proyecto se construye con Claude Code (los commits llevan Co-Authored-By: Claude), y esta vez tres ramas avanzaron en paralelo (D1, H8, preparación de la versión), así que el orden de los commits no es estrictamente el orden del trabajo. Las cifras se calculan con el mismo método que en el último texto (todos los archivos .rs fuera de vendor/ y fuzz/, funciones #[test], títulos ## ADR- en docs/DECISIONS.md) y se comprobaron sobre el commit antiguo, que da las mismas cifras de entonces.

Momento Commit Hora Líneas de Rust Pruebas ADR Commits
último texto d21e3ec 1 de noviembre, 20:41 112 408 926 126 178
hoy f1a7fbc 3 de noviembre, 02:43 115 148 962 133 215

El crecimiento del código es modesto (+2740 líneas, +36 pruebas), porque esta vez no hubo mucho código nuevo: la mayor parte del trabajo son pruebas, scripts, documentación y comprobaciones. Los scripts y el JavaScript no cuentan en la cifra de líneas.

Cifrado del disco en reposo (S7)

Lo que planeamos. Cifrado opcional de la partición de datos, desactivado por defecto, con un coste medido. Lo que construimos (ADR-240):

  • Una capa bajo el disco. CryptDev envuelve el dispositivo de bloques por debajo de Disk, de modo que el diseño, la tabla de tamaños, SQLite, la telemetría y las copias de seguridad trabajan sobre texto plano y no saben nada del cifrado. El algoritmo es AES-256-XTS (IEEE 1619), un sector de 512 bytes por unidad de datos, y no un bloque de 4 KiB como en el plan: el almacenamiento escribe en sectores, y una unidad de 4 KiB convertiría cada escritura de 512 bytes en una lectura-modificación-escritura que un corte de corriente puede partir por la mitad.
  • El superbloque queda en claro. Los dos slots del superbloque llevan una marca y una comprobación de la clave (HMAC-SHA-256 de una etiqueta y del UUID del disco), nunca la clave. Todo lo demás de la partición está cifrado. Un sector que en el medio es todo ceros se lee como ceros y no se descifra, de modo que discard, el espacio nunca escrito y el crecimiento del disco funcionan como antes.
  • Configuración. storage.encryption = "aes-256-xts" y storage.disk_key (un archivo con 64 bytes de clave). El formato del blob pasa a ser 4 solo cuando el cifrado está activado, así que todas las demás imágenes siguen arrancando en kernels anteriores. Un lector anterior rechaza la marca desconocida del superbloque.
  • Sin AES por hardware: rechazo. El kernel rechaza storage.encryption en un procesador sin instrucciones AES (o cuando [cpu] disable las desactiva), en lugar de ejecutar en silencio una versión lenta por software.
  • La conversión es sin conexión. El kernel nunca cifra ni descifra un disco al arrancar. urmqtt-disk encrypt, decrypt y rekey lo hacen con la VM apagada: el registro de conversión se escribe primero y se borra al final (mientras existe, el kernel rechaza el disco), los fragmentos de 1 MiB se guardan primero en un diario en el anfitrión, después se escribe la copia convertida, y el superbloque se cambia al final. Probado con un corte de corriente tras cada escritura individual en los tres procedimientos, limpio y a medio escribir, con la caché descartada y conservada: siempre un disco válido con todos sus datos después de --resume.

Medido. Rendimiento de XTS por núcleo en Apple silicon (un MiB de sectores en un bucle): 3109 MB/s cifrando y 3103 MB/s descifrando con AES por hardware, frente a 252 y 258 MB/s por software. Por eso no permitimos el software. db-bench en una VM (aarch64, HVF, 5000 inserciones en 50 transacciones, cinco ejecuciones de cada variante, alternadas):

ops/s, cinco ejecuciones mediana
cifrado desactivado 49 239; 47 401; 50 802; 53 417; 46 531 49 239
cifrado activado 57 790; 46 173; 45 939; 52 787; 49 632 49 632

La diferencia de las medianas es +0,8 %, y la dispersión dentro de una misma variante (de 46 000 a 58 000) es mayor que eso, así que el coste no es medible en esta prueba. Es también lo que esperamos: toda la ejecución de db-bench escribe unos 6 MB, que son unos 2 ms de AES, y una confirmación espera al flush, no al cifrado.

La entrada de diagnóstico ahora muestra también AES-XTS junto a AES-GCM (en HVF el kernel mide unos 800 MiB/s para XTS frente a unos 560 MiB/s para AES-GCM) e indica si el AES por hardware está disponible.

La entrada de diagnóstico iniciada en el emulador web de aldo.tech, en una pestaña del navegador: en el panel CRYPTO la última línea muestra AES-XTS y "hw AES". Las cifras describen el emulador (6 MiB/s), no urMQTT.
La entrada de diagnóstico iniciada en el emulador web de aldo.tech, en una pestaña del navegador: en el panel CRYPTO la última línea muestra AES-XTS y "hw AES". Las cifras describen el emulador (6 MiB/s), no urMQTT.

Lo que XTS no da. No hay autenticación: un sector cifrado modificado se descifra como ruido en bloques de 16 bytes (los CRC y el depurador lo detectan), y quien pueda escribir en el volumen puede intercambiar o revertir sectores enteros entre dos instantáneas. Protege un volumen o una instantánea robados, no una ISO robada, porque la clave está en la configuración de la imagen. Lo que sigue siendo visible: el GPT, el superbloque, el UUID y qué sectores se escribieron alguna vez.

Lo que encontramos por el camino en nuestro propio trabajo. Al escribir el capítulo sobre criptografía encontramos dos cosas. El paso que multiplica el tweak de XTS tenía una rama que dependía de un bit derivado de la clave; ahora no tiene ramas. Y la afirmación "comprobado con el vector 10 de IEEE 1619" cubría solo el primer bloque cifrado; ahora existe además una implementación de referencia ingenua (aritmética u128, bloque a bloque) que comprueba sectores enteros en ambos sentidos con 200 claves y sectores aleatorios, y la documentación dice qué se comprobó realmente.

Un arranque que rechaza una imagen modificada (H8)

El requisito de la lista de auditoría era sencillo y hasta ahora no estaba verificado: ninguna prueba mostraba que un kernel modificado no arranca. El pin del hash en limine.conf existía, pero sin prueba.

  • Una prueba que cambia un byte. scripts/tamper.py invierte un bit en cada copia del kernel (o del módulo de configuración) dentro de una ISO construida o de una imagen raw, y SMOKE_ONLY=integrity muestra que el cargador lo rechaza: 24 comprobaciones superadas, en aarch64 UEFI, x86_64 UEFI y x86_64 BIOS, con controles que sí arrancan. El mensaje de rechazo se lee del puerto serie mediante un interruptor solo para pruebas. En BIOS el mensaje llega solo a la pantalla, así que esa prueba afirma únicamente que la máquina no arranca.
  • La configuración del cargador registrada (ADR-250). limine enroll-config funciona sobre BOOTAA64.EFI, BOOTX64.EFI y la etapa BIOS, de modo que también se rechaza un limine.conf modificado (con una entrada de menú o un argumento añadidos). urmqtt-config pack --iso vuelve a registrarla.
  • La ruta de Secure Boot (ADR-251). scripts/sign-loaders.sh (claves de prueba, firma, verificación, un almacén de variables OVMF), mkiso.sh --sb-key y el grupo secureboot en x86_64: una ISO firmada arranca y se construye de forma reproducible, mientras que una sin firmar, una firmada con la clave equivocada y una cambiada después de firmar reciben Access Denied (10 comprobaciones superadas). Limine sigue rechazando un kernel modificado bajo Secure Boot.
  • Las reglas de la entrada del instalador (ADR-253). El plan hablaba de una palabra en la línea de comandos, install. En realidad el kernel de producción acepta tres (install, live, diag), y solo install escribe. La prueba (9 comprobaciones) lo ejecuta en tres máquinas con un disco en blanco cuyo hash no debe cambiar.
  • Secreto de la imagen (ADR-252). Una advertencia ruidosa cuando se construye una imagen de producción fuera del CI, y un análisis de la imagen, de los registros de construcción y de los registros serie en busca de bytes de clave en muchas codificaciones (11 comprobaciones).
  • TPM, solo ADR-254. Qué significaría sellar la clave del servidor en un vTPM o TPM 2.0. Recomendación: no construirlo para una versión centrada en VPS. No comprobamos qué proveedores ofrecen siquiera un vTPM.

Lo que esto NO significa (anotado como H8-W1 a H8-W6 en AUDIT.md): un disco instalado no arranca bajo Secure Boot porque su módulo de configuración no tiene hash (ADR-217); una ISO firmada no se puede configurar después (primero configurar, luego firmar); la ruta BIOS no tiene firma; no hay protección contra el retroceso a una versión anterior; en aarch64 el firmware de aquí no aplica Secure Boot, así que solo se verificó la firma del cargador; no hay clave del proyecto, así que las imágenes de la versión no están firmadas para Secure Boot; y no se probó ninguna máquina física.

El paquete de auditoría (H10)

docs/AUDIT.md es ahora un paquete que un auditor externo puede usar sin hacer preguntas: el sistema en una página, la base en la que confiamos (89 612 líneas de Rust propio en la imagen, 67 paquetes de terceros enlazados), las entradas y sus analizadores, la matriz de protecciones con sus pruebas (y las protecciones sin pruebas), las debilidades con su estado, una tabla de cada bucle y asignación alcanzable desde la red con su límite y su prueba, instrucciones para construir y probar, un alcance propuesto para una auditoría externa en diez áreas y una lista de red team con 31 ataques y sus resultados. SECURITY.md tiene una tabla STRIDE por componente.

  • Una corrección al primer texto. El texto del 29 de octubre dice que cada bloque unsafe tiene un comentario // SAFETY: comprobado por un lint. No era cierto: el lint (deny(unsafe_code)) solo limita dónde puede haber unsafe, y los comentarios los sigue desde H0 un inventario generado, que ya entonces encontró unos 30 sitios sin justificación y, en la auditoría, 108 (porque entretanto habían entrado nuevos tipos de sitios, por ejemplo los atributos #[unsafe(no_mangle)] y link_section). Por eso es el inventario, y no el lint, lo que ahora los mantiene en cero.
  • 889 sitios unsafe, cada uno con una justificación escrita. Los 108 que no tenían ninguna (sobre todo #[unsafe(no_mangle)] y link_section, algunos unsafe fn) recibieron un comentario (lo escribió un agente de IA, solo comentarios, y yo revisé una muestra); el inventario generado en el CI registra cada sitio nuevo sin justificación. Una justificación es una afirmación que hay que revisar, no una prueba, y ese es el punto 5 del alcance propuesto.
  • Cinco hallazgos abiertos siguen abiertos, anotados: no hay fuzzing de los analizadores de PCI y virtio, no hay una prueba que escriba en las tablas de páginas después de bloquearlas, no se midió el tiempo de ejecución de P-256, X25519 y Ed25519, no hay fuzzing de archivos SQLite deliberadamente manipulados (contenido por H6), y 53 de los 67 paquetes enlazados son excepciones de cargo-vet, no auditorías.

Errores que encontramos nosotros mismos

  • Los secretos de tráfico TLS no se borraban (H0-W2, abierto desde el primer día de la auditoría): la tabla de secretos capturados era un Vec que remove y swap_remove movían, dejando copias. Ahora la tabla funciona por slots: un slot se borra en el sitio cuando se toma, se descarta o se desaloja, y nada se mueve.
  • Una rama dependiente de la clave en XTS (arriba).
  • log-slow-flood en x86_64 fallaba ("log ring full"), también con el código fuente de v0.2.1. La causa no era un defecto, sino el dimensionamiento de la prueba: el escenario hacía trabajar a cada núcleo a 25 ms, mientras que ADR-090 lo había medido a 50 ms. En x86_64 el COM1 es E/S por puerto interceptada byte a byte, un núcleo que escribe en los destinos necesita unos 9 ms por línea, y tres núcleos a 25 ms piden más de un segundo de trabajo por segundo, así que los anillos se llenan (el desbordamiento previsto). La prueba ahora se ejecuta a 50 ms en x86_64 (superada, 30 de 30 en la ejecución que incluye los escenarios base); en aarch64 se queda en 25 ms.
  • La fusión de las ramas rompió las plantillas antiguas. El nuevo registro de la configuración en H8 exigía un marcador final en limine.conf, que las plantillas anteriores a H8 no tienen, así que urmqtt-config pack --iso y la página del constructor rechazaban todas las versiones anteriores a H8, incluidas las v0.2.x que la página ofrece. Lo detectaron las pruebas del constructor en el navegador (fallaron 4 de 100), no la prueba propia de H8. Corregido (una plantilla sin el marcador se configura como antes) y añadida una prueba que falla con el error original cuando se quita la corrección.
  • Nuestra documentación afirmaba por error que x86_64 se había verificado solo en un emulador. X11 ejecutó la imagen de producción en un VPS KVM real de Contabo (4 vCPU AMD EPYC) con TLS mutuo bajo carga. Corregido en todas partes: x86_64 se ejecutó en un VPS KVM real en el commit de H7 y en un emulador, pero el código añadido después (cifrado, integridad del arranque) no se ejecutó en KVM, y no se ha probado en hardware físico.

D1 y la oferta "tu servicio como unikernel"

El broker de demostración público está construido, pero no publicado: la configuración (modo live, ACL demo/{id}/#, MQTT en texto plano solo para el puente), un script que crea una CA de prueba publicada y certificados de demostración, un puente WebSocket a TCP (solo la biblioteca estándar de Python, límites de conexiones, de tasa y de tamaño, comprobación del origen), una página "Prueba urMQTT en vivo" con un cliente MQTT en JavaScript simple, un runbook y un modelo de amenazas. Probado en loopback y en QEMU (18 escenarios, 26 pruebas del puente). No probado: un VPS real, DNS, Caddy, la imagen de contenedor. Necesita un VPS aparte y aprobación explícita para la exposición pública.

Además, el sitio tiene ahora una oferta, Unikernel (15 idiomas, sin precios): adaptar tu servicio para que funcione como unikernel (evaluación de viabilidad, adaptación, piloto, soporte), con urMQTT como referencia. El texto indica de forma explícita los límites de la base actual: IPv4, TCP solo como servidor, KVM, Rust como opción natural, C solo como trabajador aislado.

La versión v0.3.0

La etiqueta v0.3.0 se puso el 3 de noviembre en el commit f1a7fbc, y el workflow de la versión terminó con éxito. La versión está marcada como pre-release y contiene el kernel, la ISO y la imagen de desarrollo para aarch64, la ISO plantilla (kernels para aarch64 y x86_64 y una configuración vacía, que urmqtt-config pack --iso y la página del constructor convierten en tu imagen), un SBOM CycloneDX, SHA256SUMS y su firma minisign, y atestaciones de procedencia SLSA. Verificado tras la publicación, en un directorio vacío: la firma de SHA256SUMS es válida, los cinco archivos coinciden con las sumas de comprobación, ambas atestaciones (el kernel, y la ISO con el predicado CycloneDX) se superan, el SBOM tiene 221 componentes y la plantilla se configura (pack --iso vuelve a registrar seis copias del cargador). La clave de firma sigue siendo una clave de prueba.

El orden importó y vale la pena anotarlo: la página del constructor se actualizó antes de la etiqueta (conoce el formato 4 y todas las plantillas anteriores), y solo entonces el espejo de aldo.tech recogió la versión. El espejo la listó firmada y verificada (ok, formato de configuración 4, el sha256 de la plantilla igual al de SHA256SUMS), y la página en vivo la ofrece como versión predeterminada y construye una ISO en el navegador en unos 20 segundos. Las notas de la versión dicen también lo que rara vez se escribe: el CI de GitHub no está en verde (véase abajo), el soporte es solo para la última versión (v0.2.1 y anteriores dejan de tenerlo), y la auditoría la hizo el propio proyecto.

En el sitio estuvimos a punto de hacernos daño por el camino: el script de publicación del sitio (publish.sh) ejecuta rsync --delete, y el espejo de versiones pertenece a otro usuario, así que cada publicación terminaba con el error 23 (y habría roto el botón "Publicar" del administrador del blog). Corregido con una exclusión para /urmqtt/releases/. Un hallazgo parecido: deploy.sh con --delete borra la página del constructor del generador, así que hay que restituirla antes de publicar.

Lo que todavía no está demostrado

  • El CI de GitHub está en rojo. No solo hoy: la última ejecución del CI completo antes de la etiqueta tuvo siete trabajos fallidos, y las ejecuciones hacia atrás hasta el 31 de octubre son en su mayoría rojas. Los que fallaron: lint (advertencias de shellcheck en los scripts), interoperabilidad TLS (todos los casos de wolfSSL), la cadena de suministro (la lista de excepciones vet-exemptions.md no está al día en Linux), fuzz smoke (en ese trabajo faltan los certificados de desarrollo), los sanitizers (LeakSanitizer informa de una fuga en las pruebas), las pruebas en Linux aarch64 (una prueba de la herramienta falla con "Broken pipe") y una prueba Kani para la rueda de temporizadores (timer wheel) no se supera. Todavía no hemos clasificado cuáles de estos fallos son del entorno y cuáles son defectos reales; hasta que lo hagamos, no afirmamos que la versión haya superado el CI.
  • La clave de firma es una clave de prueba, y las imágenes de la versión son compilaciones de desarrollo con certificados de prueba (salvo la plantilla).
  • Las traducciones de las páginas y de este texto a los otros 13 idiomas las escribió una IA y no las han revisado hablantes nativos.
  • El cifrado del disco no se ha medido en x86_64 (ni en KVM ni en hardware físico), y tampoco se han medido el rendimiento de la telemetría ni el tiempo de arranque con cifrado. No está en la página del constructor (necesita un diálogo para la clave).
  • No hay una auditoría de seguridad independiente. El alcance está escrito; no tenemos auditor.
  • Secure Boot es parcial (arriba), sin una máquina física.
  • Bare metal, IOMMU, el broker de demostración público: no hecho o no publicado.

Lo que sigue

  1. Clasificar y arreglar el CI (primero el entorno, luego la prueba Kani y la prueba que falla en Linux aarch64), porque no consideramos terminada una versión sin un CI en verde.
  2. Una decisión sobre D1 (un VPS aparte, DNS, aprobación para la exposición pública).
  3. Fuzzing de los analizadores de PCI y virtio y una prueba para las tablas de páginas bloqueadas (H0-W9, H0-W10).
  4. Un diálogo para la clave en la página del constructor, para que el cifrado esté disponible sin la línea de comandos.
  5. Después de la versión: bare metal (X12 a X16) e IOMMU, en un orden que depende del inventario de hardware del PC de pruebas.

El código está en github.com/zeljan-alduk/urMQTT; docs/PROGRESS.md sigue siendo la única fuente de verdad sobre lo que realmente funciona, docs/PERFORMANCE.md sobre lo que se midió y docs/AUDIT.md sobre lo que todavía no está demostrado. Pre-alfa, como antes; los informes de errores y los informes de seguridad que sigan las instrucciones de SECURITY.md son bienvenidos.

Comentarios

Normas de los comentarios

Normas de los comentarios

Los comentarios sirven para compartir experiencias y preguntas sobre los temas del blog. Estas normas mantienen el debate útil para todos.

  1. Cortesía Sin insultos, amenazas, acoso, discurso de odio ni discriminación por ningún motivo. Critique el argumento, no a la persona.
  2. Sobre el tema Un comentario debe referirse a la entrada o al debate que la sigue.
  3. Sin publicidad ni spam Sin anuncios, cadenas de mensajes ni enlaces sin contexto. Los enlaces e imágenes son bienvenidos cuando aportan al debate.
  4. Privacidad de los demás No publique datos personales de otras personas: nombres completos de particulares, direcciones, teléfonos, correos, fotos sin consentimiento.
  5. Derechos de autor Publique solo contenido que pueda compartir. Al citar, indique la fuente.
  6. Una sola identidad Use un solo apodo y no se haga pasar por otra persona.
  7. Confirmación por correo Un comentario se publica solo tras confirmar el enlace enviado por correo. La dirección no se muestra ni se usa para nada más; los comentarios sin confirmar se eliminan a las 48 horas.
  8. Moderación Los comentarios que incumplan las normas se ocultan o eliminan sin aviso, y un debate puede bloquearse. Las decisiones de los moderadores son definitivas.
  9. Responsabilidad El autor es responsable del contenido de su comentario. Los comentarios no reflejan la opinión de ALDO TECH.
  10. Denuncias Denuncie el contenido inapropiado en info@aldo.tech.