Poniższy tekst jest tłumaczeniem artykułu "Improving the security of your SSH private key files" autorstwa Martina Kleppmanna, zamieszconego na licencji CC BY 3.0.
Lepsze zabezpieczenie kluczy prywatnych SSH
Czy zastanawiałeś się kiedyś jak w zasadzie działają klucze z ~/.ssh
? Jak bardzo są bezpieczne?
Używam SSH wiele razy w ciągu dnia, podobnie jak prawdopodobnie robisz to ty - każdy git fetch
i git push
, każdy deploy, każde zalogowanie się na serwer. Ostatnio uświadomiłem sobie że SSH jest dla mnie kryptograficzną magią, z której przyzwyczaiłem się korzystać, ale której tak naprawdę nie rozumiałem. To słaba sytuacja, bo lubię wiedzieć jak działają rzeczy których używam. Urządziłem więc małą wyprawę badawczą, i poniżej jest kilka rzeczy które na niej odkryłem.
Podczas czytania na tematy związane z kryptografią, szybko można zgubić główny wątek w lawinie akronimów. Będę o nich wspominał w treści artykułu. Nie pomogą ci w zrozumieniu koncepcji, ale przydadzą się jeśli będziesz chciał wyszukać więcej szczegółów.
Krótkie przypomnienie: Jeśli kiedykolwiek używałeś uwierzytelniania za pomocą klucza publicznego, najprawdopodobniej masz w swoim katalogu domowym plik o nazwie ~/.ssh/id_rsa
bądź ~/.ssh/id_dsa
. Jest to twój klucz prywatny RSA/DSA, a w pliku ~/.ssh/id_rsa.pub
lub ~/.ssh/id_dsa.pub
znajduje się jego część publiczna. Jeśli chcesz zalogować się na jakikolwiek serwer, twój klucz publiczny musi się na nim znajdować w ~/.ssh/authorized_keys
. Kiedy próbujesz się zalogować, twój klient SSH używa podpisu cyfrowego żeby udowodnić że jest w posiadaniu klucza prywatnego. Serwer sprawdza czy ten podpis jest prawidłowy i że ten klucz publiczny posiada autoryzację do dostępu do twojego konta. Jeśli wszystko się zgadza, zezwala na dostęp.
Co więc znajduje się w pliku klucza prywatnego?
Format niezaszyfrowanego klucza prywatnego
Powszechnie rekomenduje się zabezpieczanie swoich kluczy prywatnych hasłem, żeby kradzież pliku z kluczem nie pozwalała na zalogowanie się na wszystkie twoje konta. Jeśli jednak przy tworzeniu klucza nie podasz hasła, nie zostanie on zaszyfrowany. Przyjrzyjmy się najpierw formatowi bez zabezpieczenia hasłem.
Typowy plik prywatny SSH wygląda następująco:
-----BEGIN RSA PRIVATE KEY----- MIIEogIBAAKCAQEArCQG213utzqE5YVjTVF5exGRCkE9OuM7LCp/FOuPdoHrFUXk y2MQcwf29J3A4i8zxpES9RdSEU6iIEsow98wIi0x1/Lnfx6jG5Y0/iQsG1NRlNCC aydGvGaC+PwwWiwYRc7PtBgV4KOAVXMZdMB5nFRaekQ1ksdH/360KCGgljPtzTNl 09e97QBwHFIZ3ea5Eih/HireTrRSnvF+ywmwuxX4ubDr0ZeSceuF2S5WLXH2+TV0 ... etc ... lots of base64 blah blah ... -----END RSA PRIVATE KEY-----
Klucz prywatny to struktura danych ASN.1 serializowana do ciągu binarnego za pomocą DER i następnie zakodowana Base64 do tekstu. ASN.1 jest z grubsza podobny do JSON (pozwala na użycie różnych typów danych takich jak liczby całkowite, wartości logiczne, łańcuchy tekstowe i listy/sekwencje, które mogą być zagnieżdżane tworząc strukturę drzewa). Jest często używany w kryptografii, jednak przestał być modny w pokoleniu webowym (nie mam pojęcia dlaczego, wygląda na całkiem przyzwoity format).
Żeby zajrzeć do środka, stwórzmy testowy klucz RSA bez hasła używając ssh-keygen, a następnie zdekodujmy go za pomocą asn1parse:
$ ssh-keygen -t rsa -N '' -f test_rsa_key $ openssl asn1parse -in test_rsa_key 0:d=0 hl=4 l=1189 cons: SEQUENCE 4:d=1 hl=2 l= 1 prim: INTEGER :00 7:d=1 hl=4 l= 257 prim: INTEGER :C36EB2429D429C7768AD9D879F98C... 268:d=1 hl=2 l= 3 prim: INTEGER :010001 273:d=1 hl=4 l= 257 prim: INTEGER :A27759F60AEA1F4D1D56878901E27... 534:d=1 hl=3 l= 129 prim: INTEGER :F9D23EF31A387694F03AD0D050265... 666:d=1 hl=3 l= 129 prim: INTEGER :C84415C26A468934F1037F99B6D14... 798:d=1 hl=3 l= 129 prim: INTEGER :D0ACED4635B5CA5FB896F88BB9177... 930:d=1 hl=3 l= 128 prim: INTEGER :511810DF9AFD590E11126397310A6... 1061:d=1 hl=3 l= 129 prim: INTEGER :E3A296AE14E7CAF32F7E493FDF474...
Alternatywnie, możesz wkleić tekst Base64 do wspaniałego dekodera ASN.1 w Javaskrypcie autorstwa Lapo Luchini. Jak widać, struktura jest prosta: sekwencja dziewięciu liczb. Ich znaczenie jest określone w RFC2313. Pierwsza liczba to numer wersji (0), trzecia liczba jest mała (65537) - jest to publiczny wykładnik e. Dwie ważne 2048-bitowe liczby znajdują się na pozycjach drugiej i czwartej w sekwencji: moduł RSA n oraz prywatny wykładnik d. Te liczby są używane bezpośrednio w algorytmie RSA. Pozostałe pięć liczb może zostać wyprowadzonych z n i d i znajdują się w pliku tylko dla przyśpieszenia obliczeń.
Klucze DSA są podobne, składają się z sekwencji sześciu liczb:
$ ssh-keygen -t dsa -N '' -f test_dsa_key $ openssl asn1parse -in test_dsa_key 0:d=0 hl=4 l= 444 cons: SEQUENCE 4:d=1 hl=2 l= 1 prim: INTEGER :00 7:d=1 hl=3 l= 129 prim: INTEGER :E497DFBFB5610906D18BCFB4C3CCD... 139:d=1 hl=2 l= 21 prim: INTEGER :CF2478A96A941FB440C38A86F22CF... 162:d=1 hl=3 l= 129 prim: INTEGER :83218C0CA49BA8F11BE40EE1A7C72... 294:d=1 hl=3 l= 128 prim: INTEGER :16953EA4012988E914B466B9C37CB... 425:d=1 hl=2 l= 21 prim: INTEGER
Klucze zabezpieczone hasłem
Aby utrudnić życie osoby która ukradnie twój klucz prywatny, zabezpiecza się go hasłem. Jak to działa?
$ ssh-keygen -t rsa -N 'super secret passphrase' -f test_rsa_key $ cat test_rsa_key -----BEGIN RSA PRIVATE KEY----- Proc-Type: 4,ENCRYPTED DEK-Info: AES-128-CBC,D54228DB5838E32589695E83A22595C7 3+Mz0A4wqbMuyzrvBIHx1HNc2ZUZU2cPPRagDc3M+rv+XnGJ6PpThbOeMawz4Cbu lQX/Ahbx+UadJZOFrTx8aEWyZoI0ltBh9O5+ODov+vc25Hia3jtayE51McVWwSXg wYeg2L6U7iZBk78yg+sIKFVijxiWnpA7W2dj2B9QV0X3ILQPxbU/cRAVTd7AVrKT ... etc ... -----END RSA PRIVATE KEY-----
Doszły dwie nowe linie w nagłówku, i jeśli spróbujesz zdekodować tekst Base64, odkryjesz że nie jest już prawidłowym obiektem ASN.1. Dzieje się tak dlatego, że cała struktura ASN.1 którą widzieliśmy powyżej została zaszyfrowana, a tekst Base64 jest wynikiem szyfrowania. Nagłówek mówi nam że użyty algorytm szyfrujący to AES-128 w trybie CBC. 128-bitowa liczba heksadecymalna w nagłówku DEK-Info
to wektor inicjalizujący (IV) szyfru. Wszystko to jest raczej standardowe, każda popularna biblioteka kryptograficzna poradzi sobie z tymi algorytmami.
Ale jak uzyskać klucz szyfrujący AES z hasła? Nie znalazłem nigdzie dokumentacji, więc musiałem przekopać się przez źródła OpenSSL. Znalazłem to:
- Dołącz pierwszych 8 bajtów wektora inicjalizującego (IV) na koniec hasła, bez separatora ("solenie" - salt - hasła)
- Policz skrót (hash) MD5 powstałego w ten sposób ciągu bajtów (jednokrotnie)
To wszystko. Żeby to udowodnić, rozszyfrujmy klucz prywatny ręcznie (używając IV/soli z nagłówka DEK-Info powyżej):
$ tail -n +4 test_rsa_key | grep -v 'END ' | base64 -D | # get just the binary blob openssl aes-128-cbc -d -iv D54228DB5838E32589695E83A22595C7 -K $( ruby -rdigest/md5 -e 'puts Digest::MD5.hexdigest(["super secret passphrase",0xD5,0x42,0x28,0xDB,0x58,0x38,0xE3,0x25].pack("a*cccccccc"))' ) | openssl asn1parse -inform DER
Powyższe polecenie drukuje na ekranie sekwencję liczbe z klucza RSA. Oczywiście, jeśli nie chcesz niczego udowadniać, możesz zrobić to samo w znacznie łatwiejszy sposób:
openssl rsa -text -in test_rsa_key -passin 'pass:super secret passphrase'
Analiza sposobu działania szyfrowania klucza hasłem ujawnia dwie słabości:
- Algorytm skrótu jest hardkodowany jako MD5, więc bez zmiany formatu nie da się uaktualnić funkcji skrótu na nowszą (np. SHA-1). To może być problemem jeśli MD5 okaże się być zbyt słaba.
- Funkcja skrótu jest używana jedynie raz - nie jest używane key stretching (rozciąganie długości klucza?). Jest to problemem ponieważ obliczenie zarówno MD5 jak i AES trwa niewielką ilość czasu, więc krótkie hasło będzie łatwe do złamania metodą brute-force.
Jeśli twój prywatny klucz SSH wpadnie kiedyś w niepożądane ręce, na przykład trafi w nie razem z twoim laptopem lub dyskiem na backupy, atakujący może wypróbować dużą ilość haseł nawet posiadając skromne zasoby obliczeniowe. Jeśli używasz hasła słownikowego, prawdopodobnie da się je złamać w kilka sekund.
To nienajlepsza wiadomość: hasło twojego klucza SSH nie jest tak pożyteczne jak wszystkim się wydaje. Ale jest też dobra wiadomość: można użyć bezpieczniejszego formatu klucza prywatnego, i wszystko nadal działa tak jak poprzednio!
Lepsze zabezpieczenie klucza z użyciem PKCS#8
Celem jest wyprowadzenie klucza szyfrowania symetrycznego z hasła w taki sposób, żeby wymagało to dużo długich obliczeń, a atakujący musiał dysponować większą mocą obliczeniową aby odzyskać hasło metodą brute-force. Jeśli kiedyś słyszałeś mema "używaj bcrypt", pewnie ten scenariusz brzmi bardzo znajomo.
Klucze prywatne SSH używają kilku standardów o korporacyjnie brzmiących nazwach (uwaga, akronimy!) które mogą nam pomóc:
- PKCS #5 (RFC 2898) definiuje PBKDF2 (Password-Based Key Derivation Function 2 - klucza wyprowadzenia klucza z hasła 2), algorytm który zamienia hasło w klucz szyfrujący poprzez wielokrotne obliczenie z niego funkcji skrótu. Dokument ten definiuje też PBES2 (Password-Based Encryption Scheme 2 - procedura szyfrowania za pomocą hasła), standard ten oznacza po prostu użycie klucza wygenerowanego przez PBKDF2 do szyfrowania symetrycznego.
- PKCS #8 (RFC 5208) definiuje format przechowywania kluczy prywatnych który pozwala na używanie PBKDF2. OpenSSL w sposób przezroczysty dla użytkownika obsługuje klucze prywatne w formacie PKCS#8, OpenSSH używa OpenSSL, więc jeśli używasz OpenSSH - możesz zamienić tradycyjny klucz SSH na plik PKCS#8 i wszystko nadal będzie działało normalnie.
Nie mam pojęcia dlaczego ssh-keygen
nadal generuje klucze w tradycyjnym formacie SSH, pomimo tego że lepszy format jest dostępny od lat. Kompatybilność z serwerami nie jest tu żadnym problemem, ponieważ klucz prywatny nigdy nie opuszcza twojego komputera. Na szczęście jednak konwersja do PKCS#8 jest dość łatwa:
$ mv test_rsa_key test_rsa_key.old $ openssl pkcs8 -topk8 -v2 des3 \ -in test_rsa_key.old -passin 'pass:super secret passphrase' \ -out test_rsa_key -passout 'pass:super secret passphrase'
Jeśli spróbujesz użyć nowego pliku w formacie PKCS#8 w kliencie SSH, powinienneś zauważyć że działa dokładnie tak samo jak plik wygenerowany przez ssh-keygen
. Co jest w środku takiego pliku?
$ cat test_rsa_key -----BEGIN ENCRYPTED PRIVATE KEY----- MIIFDjBABgkqhkiG9w0BBQ0wMzAbBgkqhkiG9w0BBQwwDgQIOu/S2/v547MCAggA MBQGCCqGSIb3DQMHBAh4q+o4ELaHnwSCBMjA+ho9K816gN1h9MAof4stq0akPoO0 CNvXdtqLudIxBq0dNxX0AxvEW6exWxz45bUdLOjQ5miO6Bko0lFoNUrOeOo/Gq4H dMyI7Ot1vL9UvZRqLNj51cj/7B/bmfa4msfJXeuFs8jMtDz9J19k6uuCLUGlJscP ... etc ... -----END ENCRYPTED PRIVATE KEY-----
Zauważ że zmieniły się nagłówek i stopka (BEGIN ENCRYPTED PRIVATE KEY
zamiast BEGIN RSA PRIVATE KEY
), i że zniknęły nagłówki Proc-Type
oraz DEK-Info
. Plik klucza znów jest strukturą ASN.1:
$ openssl asn1parse -in test_rsa_key 0:d=0 hl=4 l=1294 cons: SEQUENCE 4:d=1 hl=2 l= 64 cons: SEQUENCE 6:d=2 hl=2 l= 9 prim: OBJECT :PBES2 17:d=2 hl=2 l= 51 cons: SEQUENCE 19:d=3 hl=2 l= 27 cons: SEQUENCE 21:d=4 hl=2 l= 9 prim: OBJECT :PBKDF2 32:d=4 hl=2 l= 14 cons: SEQUENCE 34:d=5 hl=2 l= 8 prim: OCTET STRING [HEX DUMP]:3AEFD2DBFBF9E3B3 44:d=5 hl=2 l= 2 prim: INTEGER :0800 48:d=3 hl=2 l= 20 cons: SEQUENCE 50:d=4 hl=2 l= 8 prim: OBJECT :des-ede3-cbc 60:d=4 hl=2 l= 8 prim: OCTET STRING [HEX DUMP]:78ABEA3810B6879F 70:d=1 hl=4 l=1224 prim: OCTET STRING [HEX DUMP]:C0FA1A3D2BCD7A80DD61F4C0287F8B2D...
Dekoder ASN.1 w Javaskrypcie pokaże nam ładną strukturę drzewa:
Sequence (2 elements) |- Sequence (2 elements) | |- Object identifier: 1.2.840.113549.1.5.13 // using PBES2 from PKCS#5 | `- Sequence (2 elements) | |- Sequence (2 elements) | | |- Object identifier: 1.2.840.113549.1.5.12 // using PBKDF2 -- yay! :) | | `- Sequence (2 elements) | | |- Byte string (8 bytes): 3AEFD2DBFBF9E3B3 // salt | | `- Integer: 2048 // iteration count | `- Sequence (2 elements) | Object identifier: 1.2.840.113549.3.7 // encrypted with Triple DES, CBC | Byte string (8 bytes): 78ABEA3810B6879F // initialization vector `- Byte string (1224 bytes): C0FA1A3D2BCD7A80DD61F4C0287F8B2DAB46A43E... // encrypted key blob
Użyty tu format korztsta z OID, kodów liczbowych przydzielanych przez odpowiednią organizację które jednoznacznie identyfikują algorytmy. OID w tym kluczu mówią nam że schemat szyfrowania to pkcs5PBES2, funkcja wyprowadzenia klucza to PBKDF2 i że szyfrowanie używa algorytmu des-ede3-cbc. Plik może również zawierać informację o użytej funkcji skrótu. Tutaj ta informacja jest pominięta, co oznacza że użyta jest funkcja domyślna: hMAC-SHA1.
Pozytywnym skutkiem używania w pliku identyfikatorów jest możliwość zmiany algorytmów w przyszłości bez zmiany formatu pliku, jeśli obecnie znane algorytmy będą już złamane.
Widać także że funkcja wyprowadzania kluca używa 2048 iteracji. W porównaniu z tylko jedną iteracją w standardowym formacie pliku SSH jest lepiej - oznacza to, że złamanie hasła będzie przebiegało znacznie dłużej. Liczba 2048 jest hardkodowana w OpenSSL, jednak mam nadzieję że w przyszłości będzie konfigurowalna ponieważ na współczesnych komputerach można ją zwiększyć bez wprowadzania zauważalnego opóźnienia.
Wniosek: lepsze zabezpieczenie twoich kluczy prywatnych SSH
Jeśli twój klucz prywatny SSH jest już zabezpieczony silnym hasłem, skonwertowanie go z tradycyjnego formatu do PKCS#8 podniesie stopień skomplikowania o podobny poziom jak przedłużenie hasła o dwa znaki. Jeśli natomiast używasz słabego hasła, twój poziom zabezpieczenia podniesie sie z "łatwe do złamania" na "trochę trudniejsze".
To prostę, możesz zrobić to w tej chwili:
$ mv ~/.ssh/id_rsa ~/.ssh/id_rsa.old $ openssl pkcs8 -topk8 -v2 aes-256-cbc -in ~/.ssh/id_rsa.old -out ~/.ssh/id_rsa $ chmod 600 ~/.ssh/id_rsa # Sprawdź czy klucz po konwersji działa i skasuj stary jeśli tak $ rm ~/.ssh/id_rsa.old
Komenda openssl pkcs8
pyta o hasło trzy razy: pierwszy raz w celu odblokowania istniejącego klucza prywatnego, a następnie dwa razy o hasło do nowego klucza. Nie ma znaczenia czy dla nowego klucza wybierzesz nowe hasło, czy pozostawisz stare.
(Tutaj mały bonus od tłumacza - zmieniłem szyfrowanie z archaicznego 3DES proponowanego przez autora oryginału na 256-bitowe AES // GDR!)
Nie każde oprogramowanie czyta format PKCS8, ale to w niczym nie przeszkadza - klucz prywatny ma być czytelny tylko dla twojego klienta SSH. Z punktu widzenia serwera sposób przechowywania klucza po stronie klienta nic nie zmienia.