← Блог

· 13 мин чтения #rust#mqtt#iot#unikernel#open source#security

urMQTT v0.3.0: шифрование диска, загрузка, которая отвергает измененный образ, и то, что мы сами сломали

Что произошло после третьего текста об urMQTT: шифрование диска в покое с измеренной ценой, тест, который меняет один байт в ядре и показывает, что загрузчик его отвергает, аудиторский пакет с 889 обоснованными местами unsafe, ошибки, которые мы нашли сами (и одно утверждение, которое мы написали неверно), и релиз v0.3.0 со списком того, что еще не доказано, включая красный CI.

Третий текст, urMQTT после VPS, закончился списком «Что дальше»: публичный демо-брокер (D1), второй раунд для медленного одиночного потока, обычный путь против Mosquitto на VPS и, «после релиза», bare metal, IOMMU, Secure Boot и шифрование диска. Этот текст говорит, что из этого сделано, что нет и что мы нашли по дороге. Шифрование диска и часть Secure Boot все же вошли до релиза, потому что стояли на пути к аудиту. Второй раунд для одиночного потока и обычный путь против Mosquitto не сделаны.

Предупреждение заранее прежнее: проект находится на стадии пре-альфа, релиз нацелен на VPS с KVM, а цифры измеряют код, тесты и определенные эксперименты, а не зрелость в продакшене.

Как это выглядело во времени

С предыдущего текста (1.11. в 20:41) до сегодняшнего дня (3.11. в 02:43) в главной ветке 37 новых коммитов, всего 215. Как и раньше, проект строится с Claude Code (коммиты несут Co-Authored-By: Claude), а в этот раз параллельно работали три ветки (D1, H8, подготовка релиза), поэтому порядок коммитов не строго совпадает с порядком работы. Цифры вычислены тем же методом, что и в прошлом тексте (все файлы .rs вне vendor/ и fuzz/, функции #[test], заголовки ## ADR- в docs/DECISIONS.md) и проверены на старом коммите, который дает те же цифры, что и тогда.

Момент Коммит Время Строк Rust Тестов ADR Коммитов
прошлый текст d21e3ec 1.11. 20:41 112 408 926 126 178
сегодня f1a7fbc 3.11. 02:43 115 148 962 133 215

Рост кода скромный (+2 740 строк, +36 тестов), потому что в этот раз нового кода было немного: большая часть работы это тесты, скрипты, документация и проверки. Скрипты и JavaScript в число строк не входят.

Шифрование диска в покое (S7)

Что мы планировали. Необязательное шифрование раздела с данными, по умолчанию выключенное, с измеренной ценой. Что мы построили (ADR-240):

  • Слой под диском. CryptDev оборачивает блочное устройство под Disk, поэтому раскладка, таблица размеров, SQLite, телеметрия и резервные копии работают с открытым текстом и ничего не знают о шифровании. Алгоритм AES-256-XTS (IEEE 1619), один сектор в 512 байт на единицу данных, а не блок в 4 KiB, как в плане: хранилище пишет секторами, а единица в 4 KiB превратила бы каждую запись в 512 байт в чтение-изменение-запись, которую сбой питания может разорвать пополам.
  • Суперблок остается открытым. Два слота суперблока несут флаг и проверку ключа (HMAC-SHA-256 от метки и UUID диска), но никогда не ключ. Все остальное в разделе зашифровано. Сектор, который на носителе целиком нулевой, читается как нули и не расшифровывается, поэтому discard, никогда не записанное пространство и рост диска работают как раньше.
  • Конфигурация. storage.encryption = "aes-256-xts" и storage.disk_key (файл с 64 байтами ключа). Формат blob становится 4 только при включенном шифровании, поэтому все остальные образы по-прежнему запускаются на более старых ядрах. Более старый читатель отвергает неизвестный флаг в суперблоке.
  • Без аппаратного AES: отказ. Ядро отвергает storage.encryption на процессоре без инструкций AES (или когда [cpu] disable их отключает), вместо того чтобы молча работать на медленной программной версии.
  • Преобразование происходит вне сети. Ядро никогда не шифрует и не расшифровывает диск при запуске. urmqtt-disk encrypt, decrypt и rekey делают это при выключенной VM: запись о преобразовании пишется первой и стирается последней (пока она есть, ядро отвергает диск), куски по 1 MiB сначала сохраняются в журнал на хосте, затем записывается преобразованная копия, а суперблок переключается последним. Проверено отключением питания после каждой отдельной записи для всех трех процедур, чисто и при полузаписанном состоянии, с отброшенным и сохраненным кэшем: всегда исправный диск со всеми данными после --resume.

Измерено. Пропускная способность XTS на одно ядро на Apple silicon (один MiB секторов в цикле): 3 109 MB/s шифрование и 3 103 MB/s расшифровка с аппаратным AES, против 252 и 258 MB/s в софте. Поэтому софт мы не разрешаем. db-bench в VM (aarch64, HVF, 5 000 вставок в 50 транзакциях, пять запусков каждого варианта, вперемешку):

op/s, пять запусков медиана
шифрование выключено 49 239, 47 401, 50 802, 53 417, 46 531 49 239
шифрование включено 57 790, 46 173, 45 939, 52 787, 49 632 49 632

Разница медиан +0,8 %, а разброс внутри одного варианта (46 000 до 58 000) больше этого, поэтому цена в этом тесте не измерима. Этого и следовало ожидать: db-bench за весь запуск пишет около 6 MB, это около 2 мс AES, а коммит ждет flush, а не шифр.

В Диагностическом входе теперь есть и AES-XTS рядом с AES-GCM (на HVF ядро измеряет около 800 MiB/s для XTS против около 560 MiB/s для AES-GCM), и он показывает, доступен ли аппаратный AES.

Диагностический вход, запущенный в веб-эмуляторе на aldo.tech, во вкладке браузера: в панели CRYPTO последняя строка показывает AES-XTS и «hw AES». Цифры описывают эмулятор (6 MiB/s), а не urMQTT.
Диагностический вход, запущенный в веб-эмуляторе на aldo.tech, во вкладке браузера: в панели CRYPTO последняя строка показывает AES-XTS и «hw AES». Цифры описывают эмулятор (6 MiB/s), а не urMQTT.

Чего XTS не дает. Нет аутентификации: измененный зашифрованный сектор расшифровывается в шум блоками по 16 байт (CRC и чистильщик это находят), а тот, кто может писать на том, может подменить или откатить целые секторы между двумя снимками. Он защищает украденный том или снимок, а не украденный ISO, потому что ключ находится в конфигурации образа. Остается видимым: GPT, суперблок, UUID и то, какие секторы вообще когда-либо записывались.

Что мы по дороге нашли в собственной работе. Пока писали главу о криптографии, мы обнаружили две вещи. Шаг умножения tweak в XTS имел ветвление, зависящее от бита, полученного из ключа; теперь оно без ветвлений. И утверждение «проверено по вектору 10 из IEEE 1619» покрывало только первый зашифрованный блок; теперь есть еще и наивная эталонная реализация (арифметика u128, блок за блоком), которая проверяет целые секторы в обе стороны на 200 случайных ключах и секторах, а документация говорит, что на самом деле проверено.

Загрузка, которая отвергает измененный образ (H8)

Требование из аудиторского списка было простым и до сих пор непроверенным: ни один тест не показывал, что измененное ядро не запускается. Закрепленный хеш в limine.conf существовал, но без доказательства.

  • Тест, который меняет байт. scripts/tamper.py переворачивает один бит в каждой копии ядра (или конфигурационного модуля) внутри собранного ISO или raw-образа, а SMOKE_ONLY=integrity показывает, что загрузчик отвергает: 24 проверки пройдены, на aarch64 UEFI, x86_64 UEFI и x86_64 BIOS, с контрольными образцами, которые запускаются. Сообщение об отказе читается с последовательного порта через переключатель только для тестов. На BIOS сообщение попадает только на экран, поэтому этот тест утверждает лишь, что машина не загружается.
  • Вписанная конфигурация загрузчика (ADR-250). limine enroll-config работает на BOOTAA64.EFI, BOOTX64.EFI и на ступени BIOS, поэтому отвергается и измененный limine.conf (с добавленным пунктом меню или аргументом). urmqtt-config pack --iso вписывает заново.
  • Путь Secure Boot (ADR-251). scripts/sign-loaders.sh (пробные ключи, подпись, проверка, хранилище переменных OVMF), mkiso.sh --sb-key и группа secureboot на x86_64: подписанный ISO запускается и воспроизводимо собирается, а неподписанный, подписанный неверным ключом и измененный после подписи получают Access Denied (10 проверок пройдено). Limine и под Secure Boot по-прежнему отвергает измененное ядро.
  • Правила установочного входа (ADR-253). План говорил об одном слове в командной строке, install. Продакшен-ядро на самом деле принимает три (install, live, diag), а пишет только install. Тест (9 проверок) запускает это на трех машинах с пустым диском, хеш которого не должен измениться.
  • Секретность образа (ADR-252). Громкое предупреждение, когда продакшен-образ собирается вне CI, и сканирование образа, журналов сборки и последовательных журналов на байты ключей во многих кодировках (11 проверок).
  • TPM, только ADR-254. Что значило бы запечатать ключ сервера в vTPM или TPM 2.0. Рекомендация: не строить для релиза, нацеленного на VPS. Мы не проверяли, какие провайдеры вообще предлагают vTPM.

Что это НЕ означает (записано как H8-W1 до H8-W6 в AUDIT.md): установленный диск не загружается под Secure Boot, потому что у его конфигурационного модуля нет хеша (ADR-217); подписанный ISO нельзя потом настроить (сначала настроить, потом подписать); у пути BIOS нет подписи; нет защиты от отката на более старую версию; на aarch64 прошивка здесь не обеспечивает Secure Boot, поэтому проверена только подпись загрузчика; проектного ключа нет, поэтому образы релиза не подписаны для Secure Boot; и ни одна физическая машина не пробовалась.

Аудиторский пакет (H10)

docs/AUDIT.md теперь пакет, которым внешний аудитор может пользоваться без вопросов: система на одной странице, база, которой мы доверяем (89 612 строк собственного Rust в образе, 67 подключенных чужих пакетов), входы и их парсеры, матрица защит с тестами (и защиты без тестов), слабые места со статусом, таблица каждого достижимого по сети цикла и аллокации с границей и тестом, инструкции по сборке и тестированию, предлагаемый объем внешнего аудита в десяти областях и red-team список с 31 атакой и результатами. В SECURITY.md есть таблица STRIDE по компонентам.

  • Исправление первого текста. В тексте от 29 октября написано, что у каждого блока unsafe есть комментарий // SAFETY:, который проверяет lint. Это было неверно: lint (deny(unsafe_code)) лишь ограничивает, где может находиться unsafe, а комментарии с H0 учитывал сгенерированный перечень, который уже тогда нашел около 30 мест без обоснования, а при аудите их было 108 (потому что тем временем появились новые виды мест, например атрибуты #[unsafe(no_mangle)] и link_section). Поэтому именно перечень, а не lint, теперь держит их на нуле.
  • 889 мест unsafe, у каждого письменное обоснование. 108, у которых его не было (в основном #[unsafe(no_mangle)] и link_section, несколько unsafe fn), получили комментарий (его написал AI-агент, только комментарии, а я просмотрел выборку); сгенерированный перечень в CI фиксирует каждое новое место без обоснования. Обоснование это утверждение, которое нужно проверять, а не доказательство, и это пункт 5 предлагаемого объема.
  • Пять открытых находок остаются открытыми, записанными: нет фаззинга парсеров PCI и virtio, нет теста, который пишет в таблицу страниц после блокировки, время выполнения P-256, X25519 и Ed25519 не измерено, нет фаззинга намеренно построенных файлов SQLite (ограничено H6), и 53 из 67 подключенных пакетов это исключения cargo-vet, а не аудиты.

Ошибки, которые мы нашли сами

  • Секреты TLS-трафика не стирались (H0-W2, открыто с первого дня аудита): таблица захваченных секретов была Vec, который remove и swap_remove сдвигали, оставляя копии. Теперь таблица по слотам: слот стирается на месте, когда его берут, отбрасывают или вытесняют, ничего не сдвигается.
  • Ветвление, зависящее от ключа, в XTS (выше).
  • log-slow-flood на x86_64 падал («log ring full»), в том числе на исходном коде v0.2.1. Причина не поломка, а размерность теста: сценарий гонял каждое ядро с интервалом 25 мс, а ADR-090 измерял его на 50 мс. На x86_64 COM1 это побайтовый перехватываемый порт-I/O, одному ядру, которое пишет в синки, нужно около 9 мс на строку, а три ядра на 25 мс требуют больше секунды работы в секунду, поэтому кольца заполняются (задуманное переполнение). Теперь тест на x86_64 идет с 50 мс (прошел, 30 из 30 при запуске с базовыми сценариями); на aarch64 остается 25 мс.
  • Слияние веток сломало старые шаблоны. Повторная запись конфигурации в H8 требовала завершающего маркера в limine.conf, которого у шаблонов до H8 нет, поэтому urmqtt-config pack --iso и страница сборщика отвергали каждый релиз до H8, включая v0.2.x, которые предлагает страница. Это поймали тесты сборщика в браузере (4 из 100 упали), а не собственный тест H8. Исправлено (шаблон без маркера настраивается как раньше) и добавлен тест, который падает с исходной ошибкой, если исправление убрать.
  • Наша документация неверно утверждала, что x86_64 проверен только в эмуляторе. X11 запустил продакшен-образ на настоящем Contabo KVM VPS (4 vCPU AMD EPYC) со взаимным TLS под нагрузкой. Исправлено везде: x86_64 запускался на настоящем KVM VPS на коммите H7 и в эмуляторе, но код, добавленный после этого (шифрование, целостность загрузки), на KVM не запускался и на физическом оборудовании не пробовался.

D1 и предложение «ваш сервис как unikernel»

Публичный демо-брокер построен, но не опубликован: конфигурация (режим live, ACL demo/{id}/#, обычный MQTT только для моста), скрипт, создающий опубликованный пробный CA и демо-сертификаты, мост WebSocket-в-TCP (только стандартная библиотека Python, ограничения соединений, скорости и размера, проверка источника), страница «Попробуйте urMQTT вживую» с клиентом MQTT на обычном JavaScript, runbook и модель угроз. Протестировано на loopback и в QEMU (18 сценариев, 26 тестов моста). Не протестировано: настоящий VPS, DNS, Caddy, образ контейнера. Нужен отдельный VPS и явное разрешение на публичное размещение.

Кроме того, на сайте опубликовано и предложение, Unikernel (15 языков, без цен): адаптация вашего сервиса, чтобы он работал как unikernel (оценка осуществимости, адаптация, пилот, поддержка), с urMQTT в качестве эталона. Текст прямо называет ограничения сегодняшней основы: IPv4, TCP только как сервер, KVM, Rust как естественный выбор, C только как изолированный воркер.

Релиз v0.3.0

Тег v0.3.0 поставлен 3 ноября на коммит f1a7fbc, и workflow релиза завершился успешно. Релиз помечен как пре-релиз и содержит development-ядро aarch64, ISO и образ, шаблонный ISO (ядра для aarch64 и x86_64 и пустую конфигурацию, которую urmqtt-config pack --iso и страница сборщика превращают в ваш образ), SBOM в CycloneDX, SHA256SUMS и его подпись minisign, а также аттестации происхождения SLSA. Проверено после публикации, в пустом каталоге: подпись SHA256SUMS действительна, все пять файлов совпадают с контрольными суммами, обе аттестации (ядра и ISO с предикатом CycloneDX) проходят, в SBOM 221 компонент, а шаблон настраивается (pack --iso заново вписывает шесть копий загрузчика). Ключ подписи по-прежнему пробный.

Порядок был важен, и его стоит записать: страница сборщика обновлена до тега (она знает формат 4 и все более старые шаблоны), и только потом зеркало на aldo.tech забрало релиз. Зеркало перечислило его подписанным и проверенным (ok, формат конфигурации 4, sha256 шаблона равен указанному в SHA256SUMS), а живая страница предлагает его как релиз по умолчанию и собирает ISO в браузере примерно за 20 секунд. В примечаниях к релизу сказано и то, что пишут редко: CI на GitHub не зеленый (см. ниже), поддержка только последней версии (v0.2.1 и старше прекращаются) и что аудит провел сам проект.

На сайте мы по дороге чуть не навредили себе сами: скрипт публикации сайта (publish.sh) делает rsync --delete, а зеркало релизов принадлежит другому пользователю, поэтому каждая публикация заканчивалась ошибкой 23 (и так сломала бы кнопку «Опубликовать» в админке блога). Исправлено исключением для /urmqtt/releases/. Похожая находка: deploy.sh с --delete удаляет страницу сборщика с генератора, поэтому ее нужно вернуть перед публикацией.

Что еще не доказано

  • CI на GitHub красный. Не только сегодня: последний запуск полного CI перед тегом имел семь упавших задач, а запуски назад до 31 октября в основном красные. Падали: lint (предупреждения shellcheck в скриптах), TLS interop (все случаи wolfSSL), цепочка поставок (список исключений vet-exemptions.md не обновлен на Linux), fuzz smoke (в этой задаче не хватает development-сертификатов), санитайзеры (LeakSanitizer сообщает об утечке в тестах), тесты на Linux aarch64 (один тест инструмента падает с «Broken pipe») и одно доказательство Kani для колеса таймера (timer wheel) не проходит. Мы еще не разобрали, что из этого окружение, а что настоящий дефект; пока не сделаем этого, мы не утверждаем, что релиз прошел CI.
  • Ключ подписи пробный, а образы релиза development-сборки с пробными сертификатами (кроме шаблона).
  • Переводы страниц и этого текста на остальные 13 языков написал AI, и носители языка их не проверяли.
  • Шифрование диска не измерено на x86_64 (ни на KVM, ни на физическом оборудовании), и не измерены ни пропускная способность телеметрии, ни время запуска с шифрованием. На странице сборщика его нет (нужен диалог для ключа).
  • Независимого аудита безопасности нет. Объем написан; аудитора у нас нет.
  • Secure Boot частичный (выше), без физической машины.
  • Bare metal, IOMMU, публичный демо-брокер: не сделано или не опубликовано.

Что дальше

  1. Разобрать и починить CI (сначала окружение, затем доказательство Kani и тест, который падает на aarch64 Linux), потому что релиз без зеленого CI мы не считаем завершенным.
  2. Решение по D1 (отдельный VPS, DNS, разрешение на публичное размещение).
  3. Фаззинг парсеров PCI и virtio и тест заблокированных таблиц страниц (H0-W9, H0-W10).
  4. Диалог для ключа на странице сборщика, чтобы шифрование стало доступно без командной строки.
  5. После релиза: bare metal (X12 до X16) и IOMMU, в порядке, зависящем от инвентаря оборудования тестового PC.

Код находится на github.com/zeljan-alduk/urMQTT; docs/PROGRESS.md по-прежнему единственный источник истины о том, что действительно работает, docs/PERFORMANCE.md о том, что измерено, а docs/AUDIT.md о том, что еще не доказано. Пре-альфа, как и раньше; сообщения об ошибках и о проблемах безопасности по инструкции в SECURITY.md приветствуются.

Комментарии

Правила комментирования

Правила комментирования

Комментарии нужны для обмена опытом и вопросами по темам блога. Эти правила помогают сохранить обсуждение полезным для всех.

  1. Вежливость Без оскорблений, угроз, травли, языка вражды и дискриминации по любому признаку. Критикуйте аргумент, а не человека.
  2. По теме Комментарий должен относиться к записи или к обсуждению под ней.
  3. Без рекламы и спама Без рекламы, писем счастья и ссылок без контекста. Ссылки и изображения приветствуются, если они дополняют обсуждение.
  4. Чужая приватность Не публикуйте чужие персональные данные: полные имена частных лиц, адреса, телефоны, почту, фотографии без согласия.
  5. Авторские права Публикуйте только то, чем вправе делиться. При цитировании указывайте источник.
  6. Одна личность Используйте один ник и не выдавайте себя за другого.
  7. Подтверждение по почте Комментарий публикуется только после перехода по ссылке из письма. Адрес не показывается и ни для чего другого не используется; неподтверждённые комментарии удаляются через 48 часов.
  8. Модерация Комментарии, нарушающие правила, скрываются или удаляются без предупреждения, обсуждение может быть закрыто. Решения модераторов окончательны.
  9. Ответственность За содержание комментария отвечает его автор. Комментарии не отражают позицию ALDO TECH.
  10. Жалобы О неуместном содержании сообщайте на info@aldo.tech.