Kodowanie, haszowanie i szyfrowanie to trzy różne rzeczy
Ciąg znaków, który wygląda na pomieszany, nie jest ciągiem chronionym. Nieczytelne wyjście produkują trzy odrębne operacje, a mylenie ich jest źródłem prawdziwych błędów bezpieczeństwa.
| Odwracalne? | Wymaga klucza? | Do czego służy | |
|---|---|---|---|
| Kodowanie | Tak, przez każdego | Nie | Przeprowadzenie bajtów przez kanał, który nie uniesie ich w surowej postaci |
| Haszowanie | Nie | Nie | Odciski, kontrola integralności, klucze wyszukiwania |
| Szyfrowanie | Tak, kluczem | Tak | Poufność |
Kodowanie to zmiana alfabetu. Base64, kodowanie procentowe, zapis szesnastkowy i encje HTML zapisują tę samą informację inaczej, żeby parser dalej w łańcuchu się nią nie zakrztusił. Nie ma tu żadnego sekretu, więc nie ma czego chronić. Należy tu również przesunięcie Cezara: narzędzie do szyfru Cezara łamie od razu wszystkie 25 przesunięć naraz, co jest uczciwym podsumowaniem tego, jak bardzo stałe podstawienie chroni cokolwiek. Haszowanie odwzorowuje dowolne wejście na skrót o stałej długości i nie da się go puścić wstecz. Szyfrowanie jako jedyne z tych trzech zapewnia poufność i zawsze wiąże się z kluczem. Jeśli nie umiesz wskazać klucza, nic nie jest szyfrowane.
Base64 i jego koszt
Base64 dzieli trzy bajty (24 bity) na cztery grupy po sześć i zapisuje każdą grupę jako jeden znak z alfabetu 64 znakowego. Cztery znaki na trzy bajty oznaczają, że wynik jest o jedną trzecią większy. Ma to największe znaczenie przy osadzaniu plików: narzędzie do kodowania pliku w Base64 wytwarza adres data URL, więc obrazek o rozmiarze 300 KB ląduje w twoim HTML jako mniej więcej 400 KB, pobierane ponownie przy każdym wyświetleniu strony.
Kiedy wejście nie jest wielokrotnością trzech bajtów, ostatnia grupa jest dopełniana znakiem =:
"abc" -> YWJj (3 bytes, no padding)
"ab" -> YWI= (2 bytes, one =)
"a" -> YQ== (1 byte, two =)
Standardowy alfabet kończy się znakami + i /, co jest problemem w adresie URL, ponieważ + dekoduje się w danych formularza jako spacja, a / jest separatorem ścieżki. Istnieje więc drugi alfabet: Base64url zamienia + na -, a / na _, i zwykle pomija dopełnienie. Właśnie dlatego token skopiowany z adresu URL czasem nie przechodzi przez dekoder ustawiony na alfabet standardowy. Narzędzie do kodowania Base64 obsługuje oba i poprawnie obsługuje Unicode, czego naiwne implementacje często nie robią: tekst musi zostać zakodowany w UTF-8 przed krokiem Base64.
Nic z tego nie jest zabezpieczeniem. Wartość, którą produkt nazywa w adresie URL "zaszyfrowanym" identyfikatorem, bardzo często jest zapisem Base64 zwykłej liczby całkowitej, którą każdy może odczytać, zmienić i zakodować z powrotem w kilka sekund. To samo dotyczy ładunku tokenu JSON Web Token: dekoder JWT odczytuje go bez klucza, bo nie ma tam czego otwierać.
Kodowanie procentowe i wybór właściwej funkcji
Kodowanie procentowe zastępuje bajt znakiem % i dwiema cyframi szesnastkowymi. Działa na bajtach, a nie na znakach, więc tekst spoza ASCII jest najpierw kodowany w UTF-8: é staje się %C3%A9.
Znaki niezarezerwowane (A-Z, a-z, 0-9, -, ., _, ~) nigdy nie wymagają kodowania. Znaki zarezerwowane (: / ? # [ ] @ ! $ & ' ( ) * + , ; =) to separatory, które nadają adresowi URL strukturę, a to, czy dany znak wymaga zakodowania, zależy od tego, czy jest użyty jako separator, czy jako dane. Na tym polega różnica między dwiema funkcjami JavaScript:
encodeURI("https://ex.com/s?q=cats & dogs")
// https://ex.com/s?q=cats%20&%20dogs the & still splits the query
encodeURIComponent("cats & dogs")
// cats%20%26%20dogs safe as a single value
encodeURI służy do całego adresu URL, który chcesz uczynić poprawnym, więc zostawia separatory nietknięte. encodeURIComponent służy do jednej wartości wstawianej do segmentu ścieżki albo do parametru zapytania, więc separatory koduje. Do wszystkiego, co wstawiasz, używaj tej drugiej, i pamiętaj, że nadal zostawia ona w spokoju !, ', (, ) oraz *.
Zostaje jeszcze znak plus. Treści formularzy wysyłane jako application/x-www-form-urlencoded kodują spację jako +, a konwencja przeciekła do ciągów zapytania, więc większość serwerów dekoduje + w zapytaniu jako spację. Dosłowny plus trzeba wysłać jako %2B i dlatego alex+billing@example.com tak często dociera jako alex billing@example.com ze zniszczonym znacznikiem. W segmencie ścieżki jest odwrotnie: + to po prostu plus, a spacja to %20. Koder URL uwidacznia to wtedy, gdy wartość przeżywa jedną podróż w obie strony, a przy kolejnej się rozpada.
Escapowanie zależy od tego, gdzie wartość wyląduje
Nie istnieje jedna postać ciągu znaków "przygotowana dla sieci". Decyduje kontekst.
| Kontekst | Właściwe postępowanie |
|---|---|
| Tekst HTML | Encje dla &, < i > |
| Atrybut w cudzysłowach | Encje dla & i dla tego cudzysłowu, który go otacza, a atrybut zawsze w cudzysłowach |
Wewnątrz <script> | Escapowanie ciągu w JavaScript, a nie encje |
Adres URL w href albo src | Zakoduj wartość procentowo, zabezpiecz ją jako atrybut, a potem sprawdź schemat |
| Wartość CSS | Escapowanie CSS, a url() nigdy nie buduj z danych od użytkownika |
Przypadek skryptu bywa zaskoczeniem. Zawartość skryptu nie jest dekodowana z encji, więc encja zapisana w tym miejscu pozostaje dosłowna, a parser HTML zamyka blok na sekwencji </script wszędzie tam, gdzie ona wystąpi, w cudzysłowach czy nie:
<script>var s = "</script>";</script> <!-- block ends early -->
<script>var s = "<\/script>";</script> <!-- correct -->
Samo escapowanie nie wystarcza również przy adresach URL. Wartość zaczynająca się od javascript: wykonuje się po kliknięciu niezależnie od tego, jak starannie została zakodowana, więc sprawdzaj schemat wobec listy dozwolonych. Narzędzie do encji HTML obsługuje przypadek tekstu i atrybutu oraz dekoduje encje z powrotem, co jest zwykłą potrzebą wtedy, gdy API podwójnie zakodowało coś do postaci &amp;.
Haszowanie: który algorytm i do czego
| Algorytm | Skrót | Status |
|---|---|---|
| MD5 | 128 bitów | Złamany. Kolizje są trywialne |
| SHA-1 | 160 bitów | Złamany. Kolizje z wybranym przedrostkiem są wykonalne i tanie |
| SHA-256, SHA-512 | 256, 512 bitów | Dobre do integralności i podpisów |
| bcrypt, scrypt, Argon2 | różnie | Wyłącznie do haseł |
MD5 i SHA-1 zawodzą pod względem odporności na kolizje, co oznacza, że atakujący potrafi skonstruować dwa różne wejścia o tym samym skrócie. Wszędzie tam, gdzie skrót zastępuje dokument, są bezużyteczne: podpisy, certyfikaty, adresowanie treści, deduplikacja czegokolwiek, co dostarcza atakujący. Żaden z nich nie jest złamany pod względem przeciwobrazu, więc stara suma kontrolna MD5 nadal wyłapie przypadkowe uszkodzenie, ale żaden nie ma czego szukać w nowych pracach. Używaj SHA-256, który generator skrótów liczy zarówno dla plików, jak i dla tekstu.
Hasła to inny problem, a szybka funkcja skrótu jest tu złym narzędziem właśnie dlatego, że jest szybka. Powszechnie dostępny sprzęt wykonuje miliardy operacji SHA-256 na sekundę, więc wyciekła tabela skrótów SHA-256 z haseł jest wyciekłą tabelą haseł. bcrypt, scrypt i Argon2id są celowo wolne i regulowane, mają współczynnik pracy (a dwa ostatnie także koszt pamięciowy), który podnosisz w miarę rozwoju sprzętu, i każdy z nich zapisuje w swoim wyniku unikalną losową sól.
Skrót nie jest też podpisem. Żeby udowodnić, że wiadomość pochodzi od kogoś, kto ma klucz, użyj HMAC-SHA-256, zamiast haszować sklejony sekret i wiadomość, co jest podatne na rozszerzenie długości.
Czego skrót nie potrafi
Skrótu nie da się odwrócić obliczeniem. Da się go odwrócić przeszukiwaniem zawsze wtedy, gdy zbiór możliwych wejść jest na tyle mały, że da się go wyliczyć: zahaszuj każdy czterocyfrowy PIN, a natychmiast masz wszystkie dziesięć tysięcy skrótów, i tak samo jest z każdym kodem pocztowym, numerem telefonu czy adresem z wyciekłej listy. Zahaszowanie identyfikatora nie anonimizuje go.
Solenie, czyli unikalna losowa wartość przechowywana z każdym rekordem i domieszana do wejścia, nie utrudnia odgadnięcia jednego celu, ale zmusza atakującego do atakowania każdego rekordu osobno i czyni tablice wyliczone z góry bezwartościowymi. Przy wartościach o niskiej entropii tym, co naprawdę pomaga, jest pieprz, czyli tajny klucz trzymany poza bazą danych.
Rozpoznawanie nieznanego ciągu znaków
| Postać | Po czym poznać | Przykład |
|---|---|---|
| Zapis szesnastkowy | Tylko 0-9a-f, parzysta długość. 32 znaki to MD5, 40 to SHA-1, 64 to SHA-256 | 5d41402abc4b2a76b9719d911017c592 |
| Base64 | Mieszana wielkość liter z + i /, długość podzielna przez 4, może kończyć się na = albo == | SGVsbG8sIHdvcmxkIQ== |
| Base64url | To samo z - i _, zwykle bez dopełnienia | eyJzdWIiOiIxMjM0NSJ9 |
| Kodowanie procentowe | % z dwiema cyframi szesnastkowymi | a%20b%26c |
| JWT | Trzy części Base64url rozdzielone kropkami, zaczynające się od eyJ | eyJhbGciOi... |
| bcrypt | Dokładnie 60 znaków, zaczyna się od $2a$, $2b$ albo $2y$ i od kosztu | $2b$12$... |
| Argon2 | $argon2id$v=19$m=...,t=...,p=...$salt$hash |
eyJ warto zapamiętać: to zapis Base64 sekwencji {", więc każdy fragment zaczynający się w ten sposób jest zakodowanym obiektem JSON, tokenem albo nie.
Udane zdekodowanie nie jest dowodem, że zgadłeś poprawnie, ponieważ Base64 zamieni w bajty dowolne wejście. Sprawdź, czy wynik jest prawdopodobnym tekstem albo czy zaczyna się od znanej sygnatury pliku: zapis Base64 zaczynający się od iVBORw0KGgo to PNG, /9j/ to JPEG, JVBERi0 to PDF, a UEsDB to archiwum zip. Jeśli bajty nie wyglądają na nic, przepuść wynik przez narzędzie zamieniające tekst na postać binarną, żeby odczytać kody wprost, albo sprawdź, czy wartość nie została zakodowana procentowo przed zakodowaniem w Base64, co jest częstym podwójnym opakowaniem.