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:

  1. „2>/dev/null wyrzuca szum.” Besu w Dockerze pisze swoje linie logu na stdout, więc przekierowanie stderr niczego nie ukrywa.
  2. „Pierwszy ciąg w kształcie adresu to adres.” Zanim Besu wydrukuje adres, loguje wczytany klucz publiczny jako 0x plus 128 znaków hex. grep -o z niezakotwiczonym wzorcem 40 znaków hex radośnie dopasowuje pierwsze 40 znaków tego dłuższego ciągu, a head -1 go 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:

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:

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