Własny, szyfrowany pęk kluczy do haseł w Ruby
Weekendowy projekt: maleńki sejf na hasła z linii poleceń oparty na Ruby i OpenSSL. Rzetelny przegląd wyprowadzania kluczy, IV, szyfrowania z uwierzytelnieniem, porównania w czasie stałym — i każdego sposobu, w jaki da się kryptografię po cichu i z pewnością siebie zepsuć.
Każdego programistę w końcu swędzi to samo: sterta haseł rozrzucona po notatkach i autouzupełnianiu przeglądarki oraz uporczywe wrażenie, że musi istnieć czystszy sposób. Pewnego weekendu podrapaliśmy to swędzenie — mały pęk kluczy z linii poleceń w Ruby, szyfrowany jedną główną frazą. To projekt, który wygląda trywialnie, a okazuje się błyskawicznym kursem kryptografii stosowanej.
Najpierw zastrzeżenie, bo naprawdę jest ważne: nie używaj domowego sejfu do niczego, czego nie możesz sobie pozwolić stracić. To było ćwiczenie, a cała wartość leżała w odkryciu, na ile sposobów można napisać kod szyfrujący, który działa idealnie i jest całkowicie niebezpieczny. Ta luka — między „działa” a „bezpieczne” — to cała historia i najlepszy argument za radą, którą wszyscy powtarzają, a mało kto przyswaja: nie pisz własnej kryptografii. Oto czego nauczył nas ten projekt, błąd po błędzie.
Zacznij od modelu zagrożeń, nie od szyfru
Pierwszym odruchem jest sięgnięcie po AES. Właściwym pierwszym ruchem jest zapisanie, przed czym faktycznie się bronisz, bo to przesądza o całej reszcie. Dla osobistego sejfu realistyczne zagrożenia to: ktoś zdobywa plik z twojego dysku, skradziona kopia zapasowa, podglądający proces czytający go w spoczynku. Sejf nie próbuje bronić się przed atakującym, który skompromitował twoją działającą maszynę i potrafi czytać pamięć albo logować klawisze przy wpisywaniu głównej frazy — żadne narzędzie plikowe cię tam nie uratuje.
To zawężenie mówi nam, czego potrzebujemy: poufności pliku w spoczynku, integralności (wykrywania manipulacji) pliku oraz głównej frazy kosztownej do łamania brute-force. Mówi też, czego nie musimy wymyślać: protokołu wymiany kluczy, kryptografii asymetrycznej, forward secrecy. Wiedza o tym, czego nie budujesz, to połowa sukcesu w nieprzeinżynierowaniu tego.
Kształt całości
Projekt jest celowo nudny. Sekrety żyją w jednym blobie JSON. Blob jest szyfrowany kluczem wyprowadzonym z głównej frazy. Na dysku widać wyłącznie szyfrogram:
require "openssl"
require "json"
require "base64"
class Vault
CIPHER = "aes-256-cbc"
def initialize(path, passphrase)
@path = path
@passphrase = passphrase
end
def entries
return {} unless File.exist?(@path)
JSON.parse(decrypt(File.binread(@path)))
end
def store(name, secret)
data = entries.merge(name => secret)
File.binwrite(@path, encrypt(JSON.generate(data)))
end
end
Wystarczająco czysto. Niebezpieczeństwo jest w całości wewnątrz encrypt i
decrypt — i tam mieszka każdy ciekawy błąd.
Błąd 1: szyfrowanie bezpośrednio frazą
Kuszącą pierwszą wersją jest podanie frazy wprost jako klucza do szyfru. Jest błędna z dwóch powodów. 256-bitowy klucz AES potrzebuje 32 bajtów jednorodnie losowego materiału; ludzka fraza nie jest ani 32-bajtowa, ani jednorodna — jest krótka, o niskiej entropii i ustrukturyzowana. Potrzebujesz funkcji wyprowadzania klucza, by rozciągnąć frazę w porządny klucz i — co kluczowe — uczynić każde zgadnięcie brute-force kosztownym. W 2013 pragmatycznym, szeroko dostępnym wyborem jest PBKDF2 z wysoką liczbą iteracji i losową solą per sejf:
def derive_key(salt)
OpenSSL::PKCS5.pbkdf2_hmac_sha1(
@passphrase, salt,
100_000, # iteracje — celowo wolno
32 # klucz 256-bit
)
end
Trzy parametry, każdy nośny. Sól nie jest tajna; przechowuje się ją z szyfrogramem. Jej zadaniem jest zapewnić, że dwa sejfy z tą samą frazą wyprodukują różne klucze, niwecząc wyliczone tęczowe tablice i czyniąc łamanie każdego sejfu osobnym wysiłkiem. Liczba iteracji to twoja gałka przeciw brute-force: na tyle wysoka, by kosztować zauważalny ułamek sekundy na próbę, czego nigdy nie zauważysz, odblokowując własny sejf raz, a co jest rujnujące dla kogoś testującego miliony haseł-kandydatów. Dostrój ją do swojego sprzętu — jeśli jest niezauważalna, podnieś. Długość wyjścia musi dokładnie pasować do rozmiaru klucza twojego szyfru.
Uwaga na dziś: PBKDF2 jest twardy tylko obliczeniowo, więc atakujący z GPU lub ASIC zrównolegli go tanio. Alternatywy twarde pamięciowo (scrypt istnieje w 2013; bcrypt do haszowania haseł) podnoszą koszt dedykowanego sprzętu i są lepszym wyborem tam, gdzie są dostępne. Zasada jest ta sama — uczyń zgadywanie kosztownym — ale jak kosztownym, zależy od wybranej KDF.
Błąd 2: stały lub powtórzony wektor inicjalizacji
Tryb CBC potrzebuje wektora inicjalizacji, a awaria jest tu subtelna. Jeśli zaszyfrujesz ten sam tekst dwa razy tym samym kluczem i tym samym IV, dostaniesz identyczny szyfrogram — co zdradza, że dane nie zmieniły się między zapisami, a w niektórych konstrukcjach umożliwia wręcz ataki. Zasada jest bezwzględna: świeży, losowy IV na każde pojedyncze szyfrowanie, wygenerowany przez sam szyfr i przechowywany (jawnie — nie jest tajny) obok wyniku.
def encrypt(plaintext)
salt = OpenSSL::Random.random_bytes(16)
cipher = OpenSSL::Cipher.new(CIPHER).encrypt
cipher.key = derive_key(salt)
iv = cipher.random_iv # świeży przy każdym wywołaniu
ciphertext = cipher.update(plaintext) + cipher.final
# pakujemy sól + iv + szyfrogram, by móc to później odwrócić
[salt, iv, ciphertext].map { |p| Base64.strict_encode64(p) }.join("\n")
end
def decrypt(blob)
salt, iv, ciphertext = blob.split("\n").map { |p| Base64.strict_decode64(p) }
cipher = OpenSSL::Cipher.new(CIPHER).decrypt
cipher.key = derive_key(salt)
cipher.iv = iv
cipher.update(ciphertext) + cipher.final
end
Zwróć uwagę, że sól i IV przechowujemy razem z szyfrogramem. Początkujący je ukrywają albo, znacznie gorzej, wpisują stały IV „dla prostoty formatu”. Oba podejścia są błędne: sól i IV są zaprojektowane jako jawne, a bezpieczeństwo opiera się w całości na tajności frazy i losowości tych wartości — nigdy na ich ukrywaniu. (Dopełnienie CBC, PKCS#7, obsługuje tu OpenSSL; jest też szwem za osławionymi atakami padding-oracle, co jest kolejnym powodem, dla którego następna sekcja nie jest opcjonalna.)
Błąd 3 — ten, który dostarczyliśmy: szyfrowanie bez uwierzytelnienia
Oto błąd, który napisaliśmy i wyłapaliśmy dopiero później, bo kod cały czas działał bez zarzutu. Szyfrowanie CBC daje poufność: atakujący nie odczyta tekstu jawnego. Nie daje integralności: atakujący — albo przekłamany bit na padającym dysku — może zmanipulować szyfrogram, a przy odszyfrowaniu dostaniesz śmieci lub, w niektórych konstrukcjach, wynik, na który atakujący może wpłynąć. Prawdziwy sejf potrzebuje szyfrowania z uwierzytelnieniem: musi wykryć, że bajty się zmieniły, i odmówić odszyfrowania.
Czystą poprawką jest Encrypt-then-MAC. Po zaszyfrowaniu policz HMAC nad szyfrogramem (oraz IV i solą) drugim wyprowadzonym kluczem, zapisz go i zweryfikuj zanim w ogóle spróbujesz odszyfrować:
def mac(key, *parts)
OpenSSL::HMAC.digest("SHA256", key, parts.join)
end
# przy odszyfrowaniu, ZANIM dotkniesz szyfru:
expected = mac(mac_key, salt, iv, ciphertext)
unless OpenSSL.secure_compare(expected, stored_mac)
raise "sejf został zmanipulowany albo fraza jest błędna"
end
Dwie subtelności. Użyj osobnego klucza do MAC-a (wyprowadź dwa klucze z KDF albo
wyprowadź klucz główny i go podziel) — nigdy tego samego klucza do szyfrowania i
uwierzytelnienia. A porównanie musi być w czasie stałym: naiwne == zwraca, gdy
tylko dwa bajty się różnią, zdradzając czasem, gdzie jest pierwsza niezgodność, co
przez wiele prób pozwala atakującemu sfałszować poprawny tag bajt po bajcie.
OpenSSL.secure_compare (albo Rack::Utils.secure_compare) porównuje w czasie
niezależnym od zawartości.
Dziś pominąłbyś cały ten ręczny taniec i użył AES-256-GCM — trybu szyfru z uwierzytelnieniem, który wpina integralność w szyfrowanie i produkuje za ciebie tag uwierzytelniający, usuwając pułapki osobnego MAC-a, dzielenia klucza i porównania czasowego jednym ruchem. Ale zbudowanie wersji Encrypt-then-MAC ręcznie to dokładnie to, co pozwala zrozumieć, dlaczego GCM jest domyślną radą.
Obchodzenie się z główną frazą
Najsilniejszą kryptografię niweczy nieostrożne obchodzenie się z kluczem. Kilka
nawyków, które wymusił na nas projekt: czytaj frazę bez echa (IO::console#noecho),
nie podawaj jej jako argumentu linii poleceń (ląduje w historii powłoki i na liście
procesów widocznej dla każdego użytkownika) i nie trzymaj jej ani wyprowadzonego
klucza w pamięci dłużej, niż wymaga tego operacja. Gdy narzędzie wypisuje sekret,
wolej skopiować go do schowka z krótkim auto-czyszczeniem niż zrzucać do terminala,
który przewija się do zalogowanej sesji. Nic z tego nie jest kryptografią; wszystko
to różnica między sejfem a dziurą w kształcie sejfu.
Ile naprawdę był wart ten weekend
Pęk kluczy działa. Mieści się w jednym pliku, jest szyfrowany i uwierzytelniony w
spoczynku, a wpisanie błędnej głównej frazy zawodzi teraz głośno, zamiast zwracać
bełkot. Ale to nie artefakt jest lekcją. Lekcją jest katalog sposobów, na jakie
pewny siebie programista produkuje kod, który działa idealnie i jest całkowicie
niebezpieczny: fraza użyta jako surowy klucz, stały IV, brakujący MAC, porównanie
zdradzające czas, fraza zostawiona w ARGV. Każdy z nich kompiluje się, działa i
poprawnie obraca twoimi danymi w tę i z powrotem. Żaden nie jest bezpieczny.
To jest prawdziwy wniosek i dokładnie dlatego „nie pisz własnej kryptografii” to dobra rada — nie dlatego, że prymitywy są bezużyteczne, ale dlatego, że luki między nimi są niewidoczne, dopóki osobiście nie wpadniesz w każdą z nich. Zbuduj zabawkowy sejf raz, w swoim czasie, żeby zobaczyć te luki. A potem użyj biblioteki, która już je domknęła — i szanuj ją znacznie bardziej za cichą, nieefektowną pracę, którą wykonuje w twoim imieniu.