Adres, który był połową klucza
Krótka historia detektywistyczna ze świata infrastruktury blockchain.
Nowy operator walidatora zaimportował klucz prywatny swojego węzła do portfela i dostał inny adres niż ten, który nasz skrypt instalacyjny kazał mu zarejestrować. Miał rację, a skrypt mylił się na każdym węźle, jaki kiedykolwiek nim zbudowaliśmy.
Zgłoszenie
Projekt prowadzi prywatny blockchain na Hyperledger Besu. Część węzłów walidatorów hostują zewnętrzni operatorzy na własnych kontach chmurowych. Dajemy im stos Terraforma i skrypt instalacyjny uruchamiany jednym poleceniem. Skrypt generuje klucz węzła, uruchamia Besu i zapisuje mały plik informacyjny z trzema wartościami, które operator odsyła nam: adresem walidatora, kluczem publicznym i URL-em enode (klucz publiczny węzła plus jego IP i port, przez które łączą się peery).
Wiadomość operatora miała dwie części:
Zaimportowałem klucz prywatny do MetaMaska, ale adres konta nie zgadza się z adresem walidatora w pliku informacyjnym.
oraz:
cat na pliku klucza pokazuje klucz, ale z czymś w nawiasie na końcu.
Klucz dający zły adres brzmi jak błąd użytkownika. Może wkleił nie to, co trzeba, albo portfel oczekiwał prefiksu 0x. To był pierwszy odruch. Potem spojrzeliśmy na dołączony zrzut ekranu.
Zdradziecki szczegół
Tak wygląda układ pliku informacyjnego, na zmyślonych wartościach:
Validator Address : 0x7c1e9ad04b52f8a1c6e3d9b07a44e1f2c85d3a90
Public Key : 7c1e9ad04b52f8a1c6e3d9b07a44e1f2c85d3a90 b3f61e…(88 more hex)
└─────────── identyczne ────────────────┘
„Adres” był pierwszymi 40 znakami klucza publicznego.
To nie może się zdarzyć przypadkiem. Adres Ethereum to ostatnie 20 bajtów keccak256(klucz publiczny). To skrót i nie ma powodu w niczym przypominać klucza, z którego powstał. Kiedy wartość, która powinna być skrótem, równa się początkowi swojego wejścia, nikt nigdy nie wykonał obliczenia. Coś skopiowało ciąg znaków.
Skąd wziął się ten ciąg
Skrypt pobierał adres tak:
docker run --rm -v "$KEY_FILE:/opt/besu/key:ro" "$BESU_IMAGE" \
public-key export-address 2>/dev/null \
| grep -oE '0x[0-9a-fA-F]{40}' | head -1
Czyta się to tak: uruchom eksport, wyrzuć szum, złap pierwszą rzecz w kształcie adresu.
Kryje dwa założenia i oba były fałszywe:
- „
2>/dev/nullwyrzuca szum.” Besu w Dockerze pisze swoje linie logu na stdout, więc przekierowanie stderr niczego nie ukrywa. - „Pierwszy ciąg w kształcie adresu to adres.” Zanim Besu wydrukuje adres, loguje wczytany klucz publiczny jako
0xplus 128 znaków hex.grep -oz niezakotwiczonym wzorcem 40 znaków hex radośnie dopasowuje pierwsze 40 znaków tego dłuższego ciągu, ahead -1go bierze, bo linia logu jest pierwsza.
stdout z kontenera ───────────────────────────────────────────────────────────── … INFO … public key 0x7c1e9ad04b52…(128 hex) ← grep dopasowuje TUTAJ 0x5e0f…(prawdziwy adres) ← nigdy nieosiągnięty
Nie było błędu ani pustej wartości. Skrypt wyprodukował poprawnie sformatowany adres bez żadnego klucza prywatnego za nim. Przyjmowałby głosy on-chain, a stake wysłany na niego zostałby zablokowany na zawsze.
Klucz publiczny w pliku przechodził przez podobny potok, ale jego 128-znakowy wzorzec dopasowywał właściwy klucz, więc klucz publiczny i enode były poprawne. Zły był tylko adres.
Zwrot pierwszy: robić rollback?
Enode operatora trafił już poprzedniego dnia do konfiguracji peerów sieci. Pierwsze pytanie po naszej stronie brzmiało: czy cofnąć tę zmianę.
Nie. Enode jest zbudowany z klucza publicznego, a klucz publiczny był w porządku. Cofnięcie odłączyłoby poprawnie skonfigurowany węzeł, żeby naprawić błąd, który nigdy go nie dotknął. Wystarczyło poprawić adres, a ten mogliśmy policzyć lokalnie z klucza publicznego:
cast keccak 0x<public key> # take the last 40 hex characters
Dwa niezależne narzędzia zgodziły się co do wyniku. Operator mógł go teraz porównać ze swoim portfelem.
Zwrot drugi: przecież wcześniej działało
Skrypt nie był nowy. Żył w szablonie, a każde repozytorium walidatora skopiowano z tego szablonu. Błąd był we wszystkich: w szablonie (dwa razy, raz dla chmury i raz dla bare metal) i w trzech repozytoriach klientów.
Jeden z tych klientów przeszedł już cały onboarding, łącznie ze stakingiem, i zgłosił, że wszystko poszło dobrze. Jak to możliwe, ze złym adresem w pliku informacyjnym?
Odpowiedź daje staking. Stake wysyła się z portfela operatora, a portfel wyprowadza prawdziwy adres z klucza. Stake się udaje tylko wtedy, gdy propozycja on-chain wskazała ten sam adres. Ten operator musiał więc podać nam adres swojego portfela, a nie ten z pliku. Błąd siedział na produkcji, a człowiek po cichu go obszedł.
Poprawka
Trzy zmiany, od najważniejszej:
- Przestać wydłubywać stdout. Besu potrafi zapisać wartość do pliku przez
public-key export-address --to=<file>. Montujemy do kontenera katalog roboczy i czytamy plik. Linie logu nigdy się do niego nie zbliżają. - Dopasowywać tylko całe wartości.
grep -xE '0x[0-9a-fA-F]{40}'odrzuca podciąg dłuższego ciągu hex. Błędem było-o. - Sprawdzać relację między wartościami. Jeśli adres równa się pierwszym 40 znakom klucza publicznego, skrypt teraz zatrzymuje się z komunikatem „nie udostępniaj tego adresu”. Prawdziwy adres nie może tak wyglądać, więc to odpala wyłącznie przy błędzie parsowania.
Testowanie bez dotykania niczego prawdziwego
Nasze laptopy nie miały Dockera, więc jedyną rzeczą, której nie dało się sprawdzić lokalnie, było to, czy ta wersja Besu faktycznie wspiera --to. Zbudowaliśmy jednorazową kopię stosu na koncie deweloperskim i zrobiliśmy z niej sieć z jednym węzłem: własny genesis, pusta lista peerów, brak bootnode'ów. Wystartował, stał na zerze peerów i bloku zero i uruchomił skrypt.
Po drodze dwa małe objazdy:
- Strach przed VPC. Nowy VPC miał ten sam zakres adresów co istniejący na tym koncie. Ktoś zaproponował, żeby „dla bezpieczeństwa” wrzucić testową maszynę do istniejącego VPC. Jest odwrotnie. Stos ze świeżym state nie może modyfikować VPC, którego nie posiada, a identyczne zakresy adresów kolidują tylko wtedy, gdy sieci są połączone. Wstawienie jednorazowego węzła z otwartym portem peer-to-peer do współdzielonej sieci to ryzykowniejszy wybór.
- Tarcie infrastructure-as-code. Pierwszy plan padł z no matching EC2 Subnet found. Zanim sieć istnieje, Terragrunt podstawia wyjścia zastępcze, a zapytanie data source pytało AWS o subnet, którego ID było dosłownie samymi zerami. Później przerwany destroy zostawił swoją blokadę state, a kolejne uruchomienie padło z
412 PreconditionFailed, opisanym jako apply, choć to był destroy, i podpisanym przez nasz własny laptop.
Wynik: adres w pliku informacyjnym zgadzał się dokładnie z keccak256(klucz publiczny) i nie zaczynał się od pierwszych znaków klucza publicznego.
Luźny wątek
„Coś w nawiasie” wyjaśniono operatorowi jako prompt jego powłoki wpadający na plik klucza bez końcowego znaku nowej linii. Dodaliśmy ; echo do polecenia w pliku informacyjnym. Później, przy spisywaniu tego, znaleźliśmy wcześniejszą notatkę mówiącą, że plik klucza Besu kończy się nową linią. Ścieżka importu w skrypcie zapisuje klucz bez niej, co mogłoby pogodzić oba fakty. Nikt nie sprawdził, której ścieżki użył ten operator, i nikt nie widział tego nawiasu. Zapisaliśmy to wyjaśnienie jako prawdopodobne, nie udowodnione.
Wskazówki dla czytelnika
- CLI w kontenerach często logują na stdout.
2>/dev/nullto nie filtr. Jeśli narzędzie potrafi zapisać wynik do pliku, użyj tego. grep -ozwraca podciągi. Wydłubywanie wzorcem wartości o stałej długości z mieszanego wyjścia chętnie wytnie ją z dłuższej wartości. Użyj-x, a najlepiej w ogóle nie wydłubuj.- Zakoduj niezmiennik. Kiedy masz wartość i to, z czego jest wyprowadzona, sprawdź, czy są ze sobą powiązane tak, jak muszą. To jedna linia i wyłapałaby to przy pierwszym uruchomieniu.
- „U poprzedniego klienta działało” może znaczyć, że człowiek to obszedł. Pytaj, jak zadziałało, a nie tylko, czy zadziałało.
- Błąd w szablonie kopiuje się do każdego repozytorium z niego utworzonego. Kiedy naprawiasz jedną kopię, wypisz wszystkie pozostałe.
- Mów, czego nie zweryfikowałeś. Wiarygodne wyjaśnienie, które komuś dałeś, nie jest faktem, dopóki go nie sprawdzisz.