← Blog

· 13 min czytania #rust#mqtt#iot#unikernel#open source#security

urMQTT v0.3.0: szyfrowanie dysku, rozruch odrzucający zmieniony obraz i to, co sami zepsuliśmy

Co się wydarzyło po trzecim tekście o urMQTT: szyfrowanie dysku w spoczynku ze zmierzonym kosztem, test, który zmienia jeden bajt w jądrze i pokazuje, że loader je odrzuca, pakiet audytowy z 889 uzasadnionymi miejscami unsafe, błędy, które sami znaleźliśmy (i jedno twierdzenie, które napisaliśmy błędnie), oraz wydanie v0.3.0 z listą tego, co nadal nie zostało udowodnione, łącznie z czerwonym CI.

Trzeci tekst, urMQTT po VPS-ie, kończył się listą „Co dalej": publiczny broker demonstracyjny (D1), druga runda dla powolnego pojedynczego strumienia, zwykła ścieżka w porównaniu z Mosquitto na VPS-ie oraz, „po wydaniu", bare metal, IOMMU, Secure Boot i szyfrowanie dysku. Ten tekst mówi, co z tego zrobiono, czego nie, i co po drodze znaleźliśmy. Szyfrowanie dysku i część Secure Boota weszły jednak przed wydaniem, bo były po drodze do audytu. Druga runda dla pojedynczego strumienia i zwykła ścieżka w porównaniu z Mosquitto nie zostały zrobione.

Zastrzeżenie na wstępie pozostaje to samo: projekt jest w fazie pre-alfa, wydanie jest przeznaczone dla VPS-ów z KVM, a liczby mierzą kod, testy i określone eksperymenty, nie dojrzałość w produkcji.

Jak to wyglądało w czasie

Od poprzedniego tekstu (1 listopada, 20:41) do dziś (3 listopada, 02:43) w głównej gałęzi jest 37 nowych commitów, łącznie 215. Jak poprzednio, projekt powstaje z Claude Code (commity zawierają Co-Authored-By: Claude), a tym razem równolegle pracowały trzy gałęzie (D1, H8, przygotowanie wydania), więc kolejność commitów nie jest ściśle kolejnością pracy. Liczby obliczono tą samą metodą co w poprzednim tekście (wszystkie pliki .rs poza vendor/ i fuzz/, funkcje #[test], nagłówki ## ADR- w docs/DECISIONS.md) i sprawdzono na starym commicie, który daje takie same liczby jak wtedy.

Moment Commit Czas Linii Rusta Testów ADR-ów Commitów
poprzedni tekst d21e3ec 1 listopada, 20:41 112 408 926 126 178
dziś f1a7fbc 3 listopada, 02:43 115 148 962 133 215

Przyrost kodu jest skromny (+2 740 linii, +36 testów), bo tym razem nie powstało wiele nowego kodu: większość pracy to testy, skrypty, dokumentacja i weryfikacje. Skrypty i JavaScript nie wchodzą do liczby linii.

Szyfrowanie dysku w spoczynku (S7)

Co planowaliśmy. Opcjonalne szyfrowanie partycji z danymi, domyślnie wyłączone, ze zmierzonym kosztem. Co zbudowaliśmy (ADR-240):

  • Warstwa pod dyskiem. CryptDev owija urządzenie blokowe pod Disk, więc układ, tabela rozmiarów, SQLite, telemetria i kopie zapasowe działają na jawnym tekście i nic nie wiedzą o szyfrowaniu. Algorytm to AES-256-XTS (IEEE 1619), jeden sektor 512 bajtów na jednostkę danych, a nie blok 4 KiB jak w planie: magazyn zapisuje sektorami, a jednostka 4 KiB zamieniłaby każdy zapis 512 bajtów w odczyt-modyfikację-zapis, który utrata zasilania może przepołowić.
  • Superblok pozostaje jawny. Dwa sloty superbloku niosą flagę i sprawdzenie klucza (HMAC-SHA-256 z etykiety i UUID dysku), nigdy klucz. Wszystko inne w partycji jest zaszyfrowane. Sektor, który na nośniku jest w całości zerem, jest odczytywany jako zero i nie jest deszyfrowany, więc discard, nigdy nie zapisana przestrzeń i powiększanie dysku działają jak dotąd.
  • Konfiguracja. storage.encryption = "aes-256-xts" i storage.disk_key (plik z 64 bajtami klucza). Format bloba staje się 4 tylko przy włączonym szyfrowaniu, więc wszystkie inne obrazy nadal uruchamiają się na starszych jądrach. Starszy czytnik odrzuca nieznaną flagę w superbloku.
  • Bez sprzętowego AES: odmowa. Jądro odrzuca storage.encryption na procesorze bez instrukcji AES (lub gdy [cpu] disable je wyłącza), zamiast po cichu uruchamiać powolną wersję programową.
  • Konwersja odbywa się offline. Jądro nigdy nie szyfruje ani nie deszyfruje dysku przy starcie. urmqtt-disk encrypt, decrypt i rekey robią to przy wyłączonej maszynie wirtualnej: rekord konwersji zapisywany jest pierwszy, a usuwany ostatni (dopóki istnieje, jądro odrzuca dysk), kawałki po 1 MiB są najpierw zapisywane w dzienniku na hoście, potem zapisywana jest przekonwertowana kopia, a superblok przełączany jest na końcu. Sprawdzone przerwaniem zasilania po każdym pojedynczym zapisie dla wszystkich trzech procedur, w wariancie czystym i połowicznie zapisanym, z porzuconą i zachowaną pamięcią podręczną: zawsze poprawny dysk ze wszystkimi danymi po --resume.

Zmierzone. Przepustowość XTS na rdzeń na Apple silicon (jeden MiB sektorów w pętli): 3 109 MB/s szyfrowanie i 3 103 MB/s deszyfrowanie ze sprzętowym AES, wobec 252 i 258 MB/s programowo. Dlatego wersji programowej nie dopuszczamy. db-bench w VM (aarch64, HVF, 5 000 wstawień w 50 transakcjach, pięć uruchomień każdego wariantu, naprzemiennie):

op/s, pięć uruchomień mediana
szyfrowanie wyłączone 49 239, 47 401, 50 802, 53 417, 46 531 49 239
szyfrowanie włączone 57 790, 46 173, 45 939, 52 787, 49 632 49 632

Różnica median wynosi +0,8 %, a rozrzut w obrębie jednego wariantu (od 46 000 do 58 000) jest większy, więc koszt nie jest mierzalny w tym teście. Tego zresztą oczekujemy: cały przebieg db-bench zapisuje około 6 MB, co daje około 2 ms AES-a, a commit czeka na flush, nie na szyfr.

Wpis diagnostyczny pokazuje teraz także AES-XTS obok AES-GCM (na HVF jądro mierzy około 800 MiB/s dla XTS wobec około 560 MiB/s dla AES-GCM) oraz informuje, czy sprzętowy AES jest dostępny.

Wpis diagnostyczny uruchomiony w emulatorze webowym na aldo.tech, w karcie przeglądarki: w panelu CRYPTO ostatni wiersz pokazuje AES-XTS i „hw AES". Liczby opisują emulator (6 MiB/s), nie urMQTT.
Wpis diagnostyczny uruchomiony w emulatorze webowym na aldo.tech, w karcie przeglądarki: w panelu CRYPTO ostatni wiersz pokazuje AES-XTS i „hw AES". Liczby opisują emulator (6 MiB/s), nie urMQTT.

Czego XTS nie daje. Nie ma uwierzytelniania: zmieniony zaszyfrowany sektor deszyfruje się do szumu w blokach po 16 bajtów (CRC-e i scrubber to wykrywają), a ktoś, kto może pisać po woluminie, może podmienić lub cofnąć całe sektory między dwoma migawkami. Chroni skradziony wolumin lub migawkę, nie skradziony ISO, bo klucz jest w konfiguracji obrazu. Pozostaje widoczne: GPT, superblok, UUID i to, które sektory kiedykolwiek zapisano.

Co po drodze znaleźliśmy we własnej pracy. Pisząc rozdział o kryptografii, ustaliliśmy dwie rzeczy. Krok mnożenia tweaku w XTS miał gałąź zależną od bitu wyprowadzonego z klucza; teraz jest bez gałęzi. A twierdzenie „sprawdzone względem wektora 10 z IEEE 1619" obejmowało tylko pierwszy zaszyfrowany blok; teraz jest też naiwna implementacja referencyjna (arytmetyka u128, blok po bloku), która sprawdza całe sektory w obu kierunkach na 200 losowych kluczach i sektorach, a dokumentacja mówi, co naprawdę sprawdzono.

Rozruch odrzucający zmieniony obraz (H8)

Wymaganie z listy audytowej było proste i dotąd niezweryfikowane: żaden test nie pokazywał, że zmienione jądro się nie uruchamia. Przypięcie hasha w limine.conf istniało, ale bez dowodu.

  • Test, który zmienia bajt. scripts/tamper.py odwraca jeden bit w każdej kopii jądra (lub modułu konfiguracji) wewnątrz zbudowanego ISO lub obrazu raw, a SMOKE_ONLY=integrity pokazuje, że loader odmawia: 24 sprawdzenia zaliczone, na aarch64 UEFI, x86_64 UEFI i x86_64 BIOS, z kontrolami, które się uruchamiają. Komunikat odmowy odczytywany jest z portu szeregowego przez przełącznik wyłącznie do testów. Na BIOS-ie komunikat trafia tylko na ekran, więc ten test twierdzi tylko, że maszyna się nie uruchamia.
  • Zapisana konfiguracja loadera (ADR-250). limine enroll-config działa na BOOTAA64.EFI, BOOTX64.EFI i etapie BIOS, więc zmieniony limine.conf (dodany wpis menu lub argument) też jest odrzucany. urmqtt-config pack --iso zapisuje ją ponownie.
  • Ścieżka Secure Boot (ADR-251). scripts/sign-loaders.sh (klucze testowe, podpisywanie, weryfikacja, magazyn zmiennych OVMF), mkiso.sh --sb-key i grupa secureboot na x86_64: podpisany ISO uruchamia się i buduje powtarzalnie, a niepodpisany, podpisany złym kluczem i zmieniony po podpisaniu dostają Access Denied (10 sprawdzeń zaliczonych). Limine nadal odrzuca zmienione jądro pod Secure Bootem.
  • Reguły wpisu instalacyjnego (ADR-253). Plan mówił o jednym słowie w wierszu poleceń, install. Produkcyjne jądro w rzeczywistości przyjmuje trzy (install, live, diag), a zapisuje tylko install. Test (9 sprawdzeń) uruchamia to na trzech maszynach z pustym dyskiem, którego hash nie może się zmienić.
  • Tajność obrazu (ADR-252). Głośne ostrzeżenie, gdy obraz produkcyjny budowany jest poza CI, oraz skanowanie obrazu, logów budowy i logów szeregowych pod kątem bajtów klucza w wielu kodowaniach (11 sprawdzeń).
  • TPM, tylko ADR-254. Co by znaczyło zapieczętowanie klucza serwera w vTPM lub TPM 2.0. Rekomendacja: nie budować tego dla wydania nastawionego na VPS-y. Nie sprawdzaliśmy, którzy dostawcy w ogóle oferują vTPM.

Czego to NIE oznacza (zapisane jako H8-W1 do H8-W6 w AUDIT.md): zainstalowany dysk nie uruchamia się pod Secure Bootem, bo jego moduł konfiguracji nie ma hasha (ADR-217); podpisanego ISO nie można potem skonfigurować (najpierw skonfigurować, potem podpisać); ścieżka BIOS nie ma podpisu; nie ma ochrony przed cofnięciem do starszej wersji; na aarch64 firmware tutaj nie egzekwuje Secure Boota, więc zweryfikowano tylko podpis loadera; nie ma klucza projektu, więc obrazy wydania nie są podpisane dla Secure Boota; i żadna fizyczna maszyna nie była próbowana.

Pakiet audytowy (H10)

docs/AUDIT.md jest teraz pakietem, z którego zewnętrzny audytor może korzystać bez pytań: system na jednej stronie, baza, której ufamy (89 612 linii własnego Rusta w obrazie, 67 podlinkowanych cudzych pakietów), wejścia i ich parsery, macierz zabezpieczeń z testami (oraz zabezpieczenia bez testów), słabości ze statusem, tabela każdej pętli i alokacji osiągalnej z sieci, z jej granicą i testem, instrukcje budowania i testowania, proponowany zakres audytu zewnętrznego w dziesięciu obszarach oraz lista red team z 31 atakami i wynikami. SECURITY.md ma tabelę STRIDE dla każdego komponentu.

  • Sprostowanie pierwszego tekstu. W tekście z 29 października stoi, że każdy blok unsafe ma komentarz // SAFETY: sprawdzany przez lint. To nie było prawdą: lint (deny(unsafe_code)) tylko ogranicza, gdzie unsafe może występować, a komentarze od H0 śledzi wygenerowany inwentarz, który już wtedy znalazł około 30 miejsc bez uzasadnienia, a przy audycie 108 (bo w międzyczasie doszły nowe rodzaje miejsc, na przykład atrybuty #[unsafe(no_mangle)] i link_section). Dlatego to inwentarz, a nie lint, utrzymuje je teraz na zerze.
  • 889 miejsc unsafe, każde z pisemnym uzasadnieniem. 108, które go nie miały (głównie #[unsafe(no_mangle)] i link_section, kilka unsafe fn), dostało komentarz (napisał go agent AI, tylko komentarze, a ja przejrzałem próbkę); wygenerowany inwentarz w CI odnotowuje każde nowe miejsce bez uzasadnienia. Uzasadnienie to twierdzenie do zweryfikowania, nie dowód, i to jest punkt 5 proponowanego zakresu.
  • Pięć otwartych ustaleń pozostaje otwartych, zapisanych: brak fuzzingu parserów PCI i virtio, brak testu, który zapisuje do tablic stron po ich zablokowaniu, czas wykonania P-256, X25519 i Ed25519 nie był mierzony, brak fuzzingu celowo spreparowanych plików SQLite (ograniczone przez H6) oraz 53 z 67 podlinkowanych pakietów to wyjątki cargo-vet, nie audyty.

Błędy, które sami znaleźliśmy

  • Sekrety ruchu TLS nie były zerowane (H0-W2, otwarte od pierwszego dnia audytu): tabela przechwyconych sekretów była Vec, który remove i swap_remove przesuwały, zostawiając kopie. Teraz tabela opiera się na slotach: slot jest zerowany w miejscu, gdy zostanie pobrany, odrzucony lub wypchnięty, i nic się nie przesuwa.
  • Gałąź zależna od klucza w XTS (wyżej).
  • log-slow-flood na x86_64 się wywracał („log ring full"), także na źródle v0.2.1. Przyczyną nie była usterka, lecz zwymiarowanie testu: scenariusz napędzał każdy rdzeń co 25 ms, a ADR-090 mierzył go przy 50 ms. Na x86_64 COM1 to przechwytywane we/wy portu po bajcie, jeden rdzeń piszący do ujść potrzebuje około 9 ms na wiersz, a trzy rdzenie co 25 ms żądają ponad sekundy pracy na sekundę, więc pierścienie się zapełniają (zamierzone przepełnienie). Test działa teraz na x86_64 co 50 ms (zaliczony, 30 z 30 w przebiegu ze scenariuszami bazowymi); na aarch64 zostaje 25 ms.
  • Scalenie gałęzi zepsuło stare szablony. Ponowny zapis konfiguracji w H8 wymagał znacznika końca w limine.conf, którego szablony sprzed H8 nie mają, więc urmqtt-config pack --iso i strona konstruktora odrzucały każde wydanie sprzed H8, łącznie z v0.2.x, które strona oferuje. Wychwyciły to testy konstruktora w przeglądarce (4 ze 100 nie przeszły), a nie własny test H8. Naprawione (szablon bez znacznika konfigurowany jest jak dawniej) i dodany test, który wywraca się z pierwotnym błędem, gdy poprawkę usunąć.
  • Nasza dokumentacja błędnie twierdziła, że x86_64 zweryfikowano tylko w emulatorze. X11 uruchomił obraz produkcyjny na prawdziwym VPS-ie Contabo z KVM (4 vCPU AMD EPYC) z wzajemnym TLS pod obciążeniem. Poprawiono wszędzie: x86_64 działał na prawdziwym VPS-ie z KVM przy commicie H7 i w emulatorze, ale kod dodany później (szyfrowanie, integralność rozruchu) nie był uruchamiany na KVM i nie próbowano go na fizycznym sprzęcie.

D1 i oferta „twoja usługa jako unikernel"

Publiczny broker demonstracyjny jest zbudowany, ale nieopublikowany: konfiguracja (tryb live, ACL demo/{id}/#, zwykły MQTT tylko dla mostu), skrypt, który tworzy opublikowane testowe CA i certyfikaty demo, most WebSocket-do-TCP (tylko biblioteka standardowa Pythona, limity połączeń, szybkości i rozmiaru, sprawdzanie pochodzenia), strona „Wypróbuj urMQTT na żywo" z klientem MQTT w zwykłym JavaScripcie, runbook i model zagrożeń. Przetestowane na loopbacku i w QEMU (18 scenariuszy, 26 testów mostu). Nieprzetestowane: prawdziwy VPS, DNS, Caddy, obraz kontenera. Potrzebuje osobnego VPS-a i wyraźnej zgody na publiczne wystawienie.

Ponadto na stronie opublikowano ofertę, Unikernel (15 języków, bez cen): dostosowanie twojej usługi tak, aby działała jako unikernel (ocena wykonalności, dostosowanie, pilotaż, wsparcie), z urMQTT jako referencją. Tekst wprost podaje ograniczenia dzisiejszej podstawy: IPv4, TCP tylko jako serwer, KVM, Rust naturalny, C tylko jako odizolowany worker.

Wydanie v0.3.0

Tag v0.3.0 został założony 3 listopada na commicie f1a7fbc, a workflow wydania zakończył się sukcesem. Wydanie jest oznaczone jako pre-release i zawiera rozwojowe jądro, ISO i obraz dla aarch64, szablon ISO (jądra dla aarch64 i x86_64 oraz pusta konfiguracja, które urmqtt-config pack --iso i strona konstruktora zamieniają w twój obraz), CycloneDX SBOM, SHA256SUMS i jego podpis minisign oraz atestacje pochodzenia SLSA. Sprawdzone po publikacji, w pustym katalogu: podpis SHA256SUMS jest ważny, wszystkie pięć plików zgadza się z sumami kontrolnymi, obie atestacje (jądra oraz ISO z predykatem CycloneDX) przechodzą, SBOM ma 221 komponentów, a szablon da się skonfigurować (pack --iso ponownie zapisuje sześć kopii loadera). Klucz podpisujący nadal jest kluczem testowym.

Kolejność miała znaczenie i warto ją zapisać: strona konstruktora została zaktualizowana przed tagiem (zna format 4 i wszystkie starsze szablony), dopiero potem lustro na aldo.tech przejęło wydanie. Lustro wymieniło je jako podpisane i zweryfikowane (ok, format konfiguracji 4, sha256 szablonu równy temu z SHA256SUMS), a działająca strona oferuje je jako wydanie domyślne i buduje ISO w przeglądarce w około 20 sekund. Notatki do wydania mówią też to, o czym rzadko się pisze: CI na GitHubie nie jest zielone (patrz niżej), wsparcie jest tylko dla ostatniej wersji (v0.2.1 i starsze się kończą), a audyt wykonał sam projekt.

Na stronie przy okazji o mało nie wyrządziliśmy sobie szkody: skrypt publikujący stronę (publish.sh) robi rsync --delete, a lustro wydań należy do innego użytkownika, więc każda publikacja kończyła się błędem 23 (i zepsułaby przycisk „Opublikuj" w panelu administracyjnym bloga). Naprawione wyłączeniem dla /urmqtt/releases/. Podobne ustalenie: deploy.sh z --delete usuwa stronę konstruktora z generatora, więc trzeba ją przywrócić przed publikacją.

Co nadal nie zostało udowodnione

  • CI na GitHubie jest czerwone. Nie tylko dziś: ostatni przebieg pełnego CI przed tagiem miał siedem nieudanych zadań, a przebiegi wstecz do 31 października są w większości czerwone. Nie przeszły: lint (ostrzeżenia shellcheck w skryptach), interop TLS (wszystkie przypadki wolfSSL), łańcuch dostaw (lista wyjątków vet-exemptions.md nie jest aktualna na Linuksie), fuzz smoke (w tym zadaniu brakuje certyfikatów rozwojowych), sanitizery (LeakSanitizer zgłasza wyciek w testach), testy na Linux aarch64 (jeden test narzędzia pada z „Broken pipe") oraz jeden dowód Kani dla koła czasowego (timer wheel) nie przechodzi. Nie rozdzieliliśmy jeszcze, co z tego to środowisko, a co prawdziwa wada; dopóki tego nie zrobimy, nie twierdzimy, że wydanie przeszło CI.
  • Klucz podpisujący jest kluczem testowym, a obrazy wydania są rozwojowe z certyfikatami testowymi (poza szablonem).
  • Tłumaczenia stron i tego tekstu na pozostałe 13 języków napisała AI i nie sprawdzili ich native speakerzy.
  • Szyfrowanie dysku nie zostało zmierzone na x86_64 (ani na KVM, ani na fizycznym sprzęcie), nie zmierzono też przepustowości telemetrii ani czasu uruchamiania z szyfrowaniem. Nie ma go na stronie konstruktora (potrzebne jest okno dialogowe na klucz).
  • Nie ma niezależnego audytu bezpieczeństwa. Zakres jest napisany; audytora nie mamy.
  • Secure Boot jest częściowy (wyżej), bez fizycznej maszyny.
  • Bare metal, IOMMU, publiczny broker demonstracyjny: nie zrobione lub nieopublikowane.

Co dalej

  1. Rozdzielić i naprawić CI (najpierw środowisko, potem dowód Kani i test, który pada na aarch64 Linux), bo wydania bez zielonego CI nie uważamy za skończonego.
  2. Decyzja w sprawie D1 (osobny VPS, DNS, zgoda na publiczne wystawienie).
  3. Fuzzing parserów PCI i virtio oraz test zablokowanych tablic stron (H0-W9, H0-W10).
  4. Okno dialogowe na klucz na stronie konstruktora, aby szyfrowanie stało się dostępne bez wiersza poleceń.
  5. Po wydaniu: bare metal (X12 do X16) i IOMMU, w kolejności zależnej od inwentarza sprzętu testowego PC.

Kod jest na github.com/zeljan-alduk/urMQTT; docs/PROGRESS.md pozostaje jedynym źródłem prawdy o tym, co naprawdę działa, docs/PERFORMANCE.md o tym, co zmierzono, a docs/AUDIT.md o tym, co nadal nie zostało udowodnione. Pre-alfa, jak dotąd; zgłoszenia błędów i zgłoszenia dotyczące bezpieczeństwa według instrukcji w SECURITY.md są mile widziane.

Komentarze

Zasady komentowania

Zasady komentowania

Komentarze służą do dzielenia się doświadczeniem i pytaniami o tematy bloga. Te zasady utrzymują dyskusję użyteczną dla wszystkich.

  1. Uprzejmość Bez obelg, gróźb, nękania, mowy nienawiści i dyskryminacji z jakiegokolwiek powodu. Krytykuj argument, nie osobę.
  2. Na temat Komentarz powinien dotyczyć wpisu lub dyskusji pod nim.
  3. Bez reklam i spamu Bez reklam, łańcuszków i linków bez kontekstu. Linki i obrazy są mile widziane, gdy wnoszą coś do dyskusji.
  4. Prywatność innych Nie publikuj cudzych danych osobowych: pełnych imion i nazwisk osób prywatnych, adresów, telefonów, e-maili, zdjęć bez zgody.
  5. Prawa autorskie Publikuj tylko treści, które możesz udostępniać. Cytując, podaj źródło.
  6. Jedna tożsamość Używaj jednego pseudonimu i nie podszywaj się pod innych.
  7. Potwierdzenie e-mailem Komentarz jest publikowany dopiero po potwierdzeniu linku wysłanego e-mailem. Adres nie jest wyświetlany ani używany do niczego innego; niepotwierdzone komentarze są usuwane po 48 godzinach.
  8. Moderacja Komentarze naruszające zasady są ukrywane lub usuwane bez uprzedzenia, a dyskusja może zostać zablokowana. Decyzje moderatorów są ostateczne.
  9. Odpowiedzialność Za treść komentarza odpowiada jego autor. Komentarze nie odzwierciedlają poglądów ALDO TECH.
  10. Zgłoszenia Nieodpowiednie treści zgłaszaj na info@aldo.tech.