Kodowanie, haszowanie i szyfrowanie to trzy różne rzeczy

Czym różnią się kodowanie, haszowanie i szyfrowanie, do czego naprawdę służą Base64, kodowanie procentowe i escapowanie HTML oraz jak rozpoznać nieznany ciąg znaków na pierwszy rzut oka.

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
KodowanieTak, przez każdegoNiePrzeprowadzenie bajtów przez kanał, który nie uniesie ich w surowej postaci
HaszowanieNieNieOdciski, kontrola integralności, klucze wyszukiwania
SzyfrowanieTak, kluczemTakPoufność

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.

KontekstWłaściwe postępowanie
Tekst HTMLEncje dla &, < i >
Atrybut w cudzysłowachEncje 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 srcZakoduj wartość procentowo, zabezpiecz ją jako atrybut, a potem sprawdź schemat
Wartość CSSEscapowanie 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;amp;.

Haszowanie: który algorytm i do czego

AlgorytmSkrótStatus
MD5128 bitówZłamany. Kolizje są trywialne
SHA-1160 bitówZłamany. Kolizje z wybranym przedrostkiem są wykonalne i tanie
SHA-256, SHA-512256, 512 bitówDobre do integralności i podpisów
bcrypt, scrypt, Argon2różnieWyłą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 szesnastkowyTylko 0-9a-f, parzysta długość. 32 znaki to MD5, 40 to SHA-1, 64 to SHA-2565d41402abc4b2a76b9719d911017c592
Base64Mieszana wielkość liter z + i /, długość podzielna przez 4, może kończyć się na = albo ==SGVsbG8sIHdvcmxkIQ==
Base64urlTo samo z - i _, zwykle bez dopełnieniaeyJzdWIiOiIxMjM0NSJ9
Kodowanie procentowe% z dwiema cyframi szesnastkowymia%20b%26c
JWTTrzy części Base64url rozdzielone kropkami, zaczynające się od eyJeyJhbGciOi...
bcryptDokł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.