Deszyfrowanie AES nie działa: klucz, IV, tryb i dopełnienie
Kiedy w logach pojawia się informacja, że deszyfrowanie AES nie działa, komunikat najprawdopodobniej opisuje zupełnie inny problem niż ten, który faktycznie wystąpił. Cztery niepowiązane ze sobą błędy dają niemal identyczne objawy, a ten najczęstszy, czyli zły klucz, zgłasza się jako błąd dopełnienia (padding).
Przy BadPaddingException z deszyfrowania CBC najwięcej czasu oszczędza taka kolejność sprawdzania:
- Bajty klucza są różne po obu stronach. Z dużą przewagą najbardziej prawdopodobna przyczyna.
- Wyprowadzanie klucza przebiega inaczej. To samo passphrase, inna funkcja KDF albo inna liczba iteracji, więc inne bajty klucza.
- Szyfrogram uszkodził się w tranzycie: został obcięty, base64 zostało zniekształcone albo dane przeszły cykl konwersji przez kodowanie tekstowe.
- Wektor inicjujący (IV) jest zły. Zdarza się naprawdę, ale nie powoduje błędu dopełnienia. Psuje szesnaście bajtów i milczy.
Ta kolejność wynika ze struktury algorytmu. CBC sprawdza dopełnienie jako ostatni krok deszyfrowania, już po zastosowaniu klucza i rozplątaniu łańcucha. Dopełnienie jest więc sumą kontrolną wszystkiego, co dzieje się wcześniej, i protestuje głośno bez względu na to, który z wcześniejszych elementów zawiódł. Dalej znajdują się pomiary; można też od razu wkleić szyfrogram do narzędzia do odszyfrowywania AES i przejść bisekcję z sekcji 9.
Wszystko poniżej zmierzono na java 1.8.0_162, node v25.8.2 i openssl 3.6.2. Domyślne ustawienia zmieniają się między wersjami, więc numery wersji należy traktować jako część wyniku.
1. Co komunikat błędu naprawdę wyklucza
Komunikat o awarii AES prawie nic nie mówi o przyczynie, za to sporo o tym, czym ta przyczyna być nie może. Przydaje się więc do skreślania gałęzi, choć winnego nie wskaże. Po polsku ta sama operacja nazywa się deszyfrowaniem, odszyfrowywaniem albo rozszyfrowaniem, więc szukając rozwiązania, trzeba sprawdzić wszystkie trzy formy. Sam komunikat błędu i tak przychodzi po angielsku, bo biblioteki go nie tłumaczą.
| Co widać | Co to wyklucza | Co wciąż wchodzi w grę |
|---|---|---|
BadPaddingException, bad decrypt, wrong final block length | GCM; sam błąd IV; nieudane dekodowanie | zły klucz, zła funkcja KDF, obcięty szyfrogram, bajty IV zjedzone jako szyfrogram, niezgodność trybu, niezgodność schematu dopełnienia |
GCM Authentication failed, Unsupported state or unable to authenticate data | dopełnienie; każdą teorię zakładającą częściowy wynik | zły klucz, zły nonce, tag odłączony lub w złym miejscu, zła długość tagu, niezgodne AAD |
| Brak wyjątku, wynik to śmieci | każdy tryb uwierzytelniany | ECB, CTR, CBC, któremu się poszczęściło, niezgodność trybu, zły IV |
BadPaddingException, bad decrypt, wrong final block length
To samo zdarzenie w trzech ekosystemach: Java, OpenSSL i .NET. Odpala na końcu deszyfrowania CBC albo ECB, kiedy ostatni blok tekstu jawnego nie kończy się poprawnym wzorcem PKCS#7.
Najwięcej wnosi tu przeczenie. Skoro błąd doszedł aż tutaj, to base64 albo hex zdekodowały się poprawnie, a liczba bajtów była niezerową wielokrotnością 16. Transport nie posiekał danych i nie chodzi o GCM. Wyjątkiem jest wrong final block length: tam liczba bajtów nie była wielokrotnością 16, co wskazuje na obcięcie, a nie na klucz, więc od razu do sekcji 8.
GCM Authentication failed i podobne
GCM porównuje tag, zanim wypuści choćby jeden bajt tekstu jawnego, dokładnie tak, jak wymaga tego NIST SP 800-38D. Jest przez to uczciwszy od błędu dopełnienia: coś w krotce (klucz, nonce, szyfrogram, dodatkowe uwierzytelniane dane, tag) nie zgadza się z tym, czego użyła strona szyfrująca. Który to element, tego GCM nie powie i nigdy nie powie, bo zawężanie leży celowo poza zakresem algorytmu. Sekcja 6 pokazuje, co przy przejściu między językami psuje się najczęściej: pozycję tagu, nie jego wartość.
Brak błędu, ale wynik to śmieci
To najgroźniejszy wynik, bo dashboard zapisuje go jako sukces. CTR nigdy nie rzuca wyjątku, ECB też nie. CBC rzuca tylko wtedy, gdy końcowy wzorzec bajtów nie przejdzie kontroli dopełnienia, a przy złym kluczu ten bajt jest praktycznie losowy, więc mniej więcej jedna próba na 256 trafia na 0x01 i przechodzi walidację. Niecałe 0,4% deszyfrowań CBC ze złym kluczem „kończy się powodzeniem”. Śmieci mają jednak swój kształt, a po kształcie da się poznać błąd: sekcje 4 i 5 opisują dwa odciski palca warte zapamiętania.
2. Najbardziej mylący błąd w AES
Ten pomiar przestawia większości ludzi kolejność diagnozowania. Klucz 0123456789abcdef, IV z samych zer, AES/CBC/PKCS5Padding, tekst jawny hello world, na java 1.8.0_162 z wbudowanym w JDK dostawcą SunJCE:
| Scenariusz | Zmiana | Zmierzony wynik |
|---|---|---|
| A | Klucz zły o 1 bajt (ostatni znak f → X) | rzuca javax.crypto.BadPaddingException: Given final block not properly padded. Samo dopełnienie nigdy nie było zniekształcone; komunikat jest całkowicie mylący |
| B | Klucz poprawny, IV zły o 1 bajt | brak wyjątku, tekst jawny hello world wrócił jako iello world. Uszkodzony został tylko odpowiadający bajt pierwszego bloku |
| C | Klucz poprawny, szyfrogram CBC odszyfrowany przez AES/ECB | po cichu się udało, żadnego wyjątku. Niezgodność trybu nie musi niczego zgłaszać |
Scenariusz A potrafi zmarnować całe popołudnie. Scenariusz C wysyła złe dane na produkcję.
Dlaczego zły klucz daje błąd dopełnienia
Dopełnienie było w porządku. Strona szyfrująca dopisała pięć bajtów 0x05, żeby hello world urosło do szesnastu, i zaszyfrowała ten blok; w szyfrogramie leży on nietknięty.
Awaria zdarza się na wyjściu. Deszyfrowanie CBC uruchamia szyfr blokowy wstecz, XOR-uje każdy wynik z poprzednim blokiem szyfrogramu i dopiero potem czyta ogon ostatniego bloku, żeby zdecydować, ile bajtów odciąć. Przy złym kluczu szyfr produkuje szesnaście bajtów szumu, a szum prawie nigdy nie kończy się poprawnym wzorcem PKCS#7. Biblioteka raportuje to, co zobaczyła, czyli złe dopełnienie. Prawda, tyle że bezużyteczna.
BadPaddingException należy czytać jako „odtworzony tekst jawny nie kończy się tak, jak kończy się dopełniony tekst jawny”. Najbardziej prawdopodobnym powodem błędnego odtworzenia jest klucz, i właśnie dlatego wyszukiwanie aes decrypt wrong key oraz wyszukiwanie błędu bad padding exception prowadzą do tych samych wątków: te dwa objawy są jednym objawem. Jedna uwaga projektowa na marginesie: tego rozróżnienia nigdy nie wolno pokazywać wywołującemu, bo odróżnienie „dopełnienie nieprawidłowe” od „dopełnienie prawidłowe, treść zła” to dokładnie to, czym żywi się atak typu padding oracle (Vaudenay, EUROCRYPT 2002).
Co GCM robi inaczej
GCM odwraca kolejność i weryfikuje tag przed wyprodukowaniem jakiegokolwiek tekstu jawnego, więc nie ma okna, w którym istnieją częściowo poprawne bajty. Po awarii GCM nikt nie zastanawia się, czy wynik jest prawdziwy, bo wyniku nie ma. GCM nie ma też w ogóle dopełnienia, bo pod spodem jest trybem licznikowym, więc długość szyfrogramu równa się długości tekstu jawnego. Błąd dopełnienia w systemie uważanym za GCM dowodzi zatem, że ten system to nie GCM; zwykle chodzi o konfigurację, która cofnęła się do CBC.
3. Czy obie strony używają tych samych bajtów klucza?
AES nie widzi ciągu znaków klucza. Widzi 16, 24 albo 32 bajty. Dwa systemy mogą trzymać w pliku konfiguracyjnym identyczny materiał klucza i mimo to się nie zgadzać, bo „identyczność” jest własnością tekstu, a nie bajtów.
Trzy sposoby zamiany ciągu klucza na bajty
Wystarczy podać dosłowny ciąg 0123456789abcdef trzem różnym bibliotekom:
jako hex -> 8 bajtów (nieprawidłowa długość klucza AES)
jako base64 -> 12 bajtów (nieprawidłowa długość klucza AES)
jako surowy UTF-8 -> 16 bajtów (prawidłowy AES-128)
Szesnaście znaków, trzy różne liczby bajtów. Ten przypadek jest paskudny dlatego, że jest poprawny przy wszystkich trzech odczytach: każdy znak należy zarówno do alfabetu hex, jak i base64, a szesnaście znaków to dozwolona długość dla obu dekoderów, więc na etapie parsowania nic nie zgłosi błędu.
Przewodnik po błędzie „invalid signature” w JWT zawiera pełną macierz tego, jak poszczególne ekosystemy interpretują ciąg sekretu; w skrócie dla AES: trzeba spisać, w jakim kodowaniu jest materiał klucza, i zmusić obie strony do jawnego dekodowania. Ta sama pułapka w wersji HMAC gryzie odbiorniki webhooków; opisuje ją przewodnik po weryfikacji podpisu webhooka.
AES jest rygorystyczny: dokładnie 16, 24 lub 32 bajty
W tym miejscu AES różni się od prymitywu, który większość programistów poznaje jako pierwszy. HMAC przyjmuje klucz dowolnej długości: RFC 2104 oblicza hash ze wszystkiego, co jest dłuższe niż rozmiar bloku, a wszystko krótsze uzupełnia zerami. Generator HMAC bez słowa skargi przyjmie więc sekret 7-bajtowy i 700-bajtowy. AES ma dokładnie trzy dozwolone długości klucza i odrzuca wszystko inne, zanim przetworzy choćby jeden blok.
Ta surowość akurat pomaga, bo błąd długości jest jedyną awarią AES, która nazywa własną przyczynę zamiast chować się za dopełnieniem. Nasze narzędzie formułuje go tak: Key must be 16, 24, or 32 bytes (AES-128/192/256). Pułapki, które prowadzą do złej długości:
- Znak nowej linii na końcu, z
KEY=$(cat key.txt)alboecho "$KEY". Zamiast tegoprintfiecho -n. Spacja na końcu, wklejona z interfejsu menedżera sekretów, robi dokładnie to samo. - Prefiks
0xskopiowany z debuggera: trzydzieści cztery znaki, które przestały być poprawnym hexem. - Znaki spoza ASCII.
contraseñato 10 znaków i 11 bajtów w UTF-8, więc „32-znakowe” passphrase z jedną literą diakrytyczną ma 33 bajty.
SecretKeySpec i domyślne kodowanie platformy
Java ma własną odmianę tego problemu, która ujawnia się dopiero po wdrożeniu. "my secret".getBytes() bez argumentu używa domyślnego kodowania platformy, które przed JDK 18 brało się z właściwości file.encoding, a więc z systemu operacyjnego i ustawień regionalnych maszyny. Laptop na UTF-8 i kontener na ANSI_X3.4-1968 dają różne bajty dla każdego znaku spoza ASCII. JEP 400 uczynił UTF-8 domyślnym kodowaniem w JDK 18, co naprawia nowy kod i nic poza tym.
// źle: bajty zależą od maszyny
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(), "AES");
// dobrze: bajty nie zależą od niczego
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "AES");
Jeżeli kod działa lokalnie, na serwerze przewraca się z błędem dopełnienia, a passphrase zawiera cokolwiek spoza ASCII, to jest pierwsza rzecz do sprawdzenia.
4. IV: gdzie trafia i jak wygląda ten zły
aes iv mismatch to awaria, którą podejrzewa się jako pierwszą, a diagnozuje jako ostatnią, bo zachowuje się inaczej niż pozostałe. Jest cicha i lokalna.
Zły IV psuje dokładnie jeden blok
Jeszcze raz scenariusz B. Klucz poprawny, IV zły o jeden bajt:
hello world -> iello world
Żadnego wyjątku, jeden znak. Wystarczy rozpisać krok CBC dla pierwszego bloku i wszystko widać: P1 = D(C1) XOR IV. IV wchodzi przez XOR prosto w pierwszy blok tekstu jawnego i niczego innego nie dotyka, więc przestawienie jednego bitu IV przestawia ten sam bit tekstu jawnego na tej samej pozycji. Tutaj h (0x68) stało się i (0x69), czyli pierwszy bajt IV przesunął się dokładnie o 0x01.
Odcisk palca jest prosty. W CBC pierwsze 16 bajtów to śmieci, a wszystko po nich jest czyste: zły jest IV, klucz dobry. Każdy blok to śmieci: zły jest klucz. Ta jedna obserwacja rozdziela dwie najczęstsze przyczyny bez zmiany choćby linijki kodu, a narzędzie do odszyfrowywania AES pokazuje zdekodowane bajty, więc da się to odczytać wprost.
Dlaczego nic nie poleciało? hello world ma 11 bajtów, czyli mieści się w jednym bloku, a dopełnienie PKCS#7 siedzi w jego bajtach od 11 do 15. Zmieniony bajt IV to bajt 0, więc obszar dopełnienia pozostał nietknięty i przeszedł walidację. Wystarczy uszkodzić bajt IV na pozycji 11 lub dalszej, żeby zamiast tego dostać błąd dopełnienia. To kolejna droga, którą błąd dopełnienia potrafi skłamać.
Trzy konwencje przesyłania
Nie istnieje standard określający, gdzie trafia IV; są tylko trzy zwyczaje, które kiepsko ze sobą współpracują.
Pierwsza konwencja dokleja IV z przodu: iv || ciphertext. To układ najczęstszy i domyślny w naszych narzędziach. Obie strony muszą się zgodzić, ile odciąć: 16 bajtów dla CBC i CTR, 12 dla GCM. Lustrzany błąd to producent, który dokleja, i konsument, który tego nie robi. Pierwsze 16 bajtów „szyfrogramu” jest wtedy wektorem IV, wszystkie bloki się przesuwają i wychodzi błąd dopełnienia.
Druga trzyma IV w osobnym polu: {"iv": "...", "ciphertext": "..."}. Jest z zasady czystsza, a przy okazji podwaja liczbę miejsc, w których kodowanie może się rozjechać, bo IV ma teraz własne pytanie base64 kontra hex.
Trzecia to stała, zwykle same zera zaszyte w kodzie, bo ktoś potrzebował determinizmu. Współpracuje idealnie i to właśnie czyni ją groźną: w CBC stały IV zdradza równość między rekordami, a w GCM ponowne użycie nonce’a pod tym samym kluczem ujawnia XOR obu tekstów jawnych i może odsłonić podklucz GHASH, który uwierzytelnia tag. SP 800-38D wypowiada się o unikalności jednoznacznie.
Przełącznik surowego szyfrogramu w narzędziu plus jawne nadpisanie IV pozwalają przetestować wszystkie trzy konwencje na tych samych bajtach w minutę.
IV w GCM ma 12 bajtów, nie 16
Zespoły, które wdrażają GCM przez przerobienie istniejącej ścieżki CBC, przenoszą ze sobą 16-bajtowy IV, a wynik przewraca się bez żadnej wskazówki.
SP 800-38D standaryzuje IV o długości 96 bitów. Inne długości są dozwolone, ale nie są po prostu „dłuższym IV”: kiedy IV nie ma 96 bitów, GCM wyprowadza początkowy blok licznika, przepuszczając IV przez GHASH, zamiast używać go bezpośrednio. Te same 16 bajtów użyte jako nonce dają więc zupełnie inny strumień klucza i inny tag niż dałoby pierwsze 12 bajtów, a w efekcie pojawia się ogólny błąd uwierzytelniania. Jeżeli szyfrogram przyszedł skądinąd i układ trzeba zgadywać, lepiej liczyć od końca: tag to ostatnie 16 bajtów, a nonce prawie zawsze pierwsze 12.
5. Niezgodność trybu, także ta cicha
Cipher.getInstance("AES") to ECB
Java pozwala nazwać szyfr bez podania trybu i schematu dopełnienia. Nie odmawia i nie ostrzega. Pod wbudowanym w JDK dostawcą SunJCE wypełnia luki wartościami ECB i PKCS5Padding.
Do udowodnienia tego trzeba właściwego eksperymentu: zaszyfrować 32 identyczne bajty (dwa bloki znaku A) kluczem 0123456789abcdef i sprawdzić, czy oba bloki szyfrogramu są takie same. Na java 1.8.0_162:
getInstance("AES") ciphertext = 3bfd04cc0d7ed55358e2cbe19de213833bfd04cc0d7ed55358e2cbe19de21383377222e061a924c591cd9c27ea163ed4
block1 = 3bfd04cc0d7ed55358e2cbe19de21383
block2 = 3bfd04cc0d7ed55358e2cbe19de21383 <- identyczne bloki = odcisk palca ECB (struktura tekstu jawnego wycieka)
getInstance("AES/CBC/PKCS5Padding") bloki się różnią = łańcuchowanie działa
Bajt w bajt identyczne. To jest sygnatura ECB: ta sama własność, przez którą słynny zaszyfrowany obrazek pingwina nadal wygląda jak pingwin. Eksperyment działa wyłącznie na identycznych blokach tekstu jawnego: szesnaście bajtów A, po których idzie szesnaście bajtów B, daje pod ECB także dwa różne bloki szyfrogramu i można by błędnie wywnioskować, że domyślnym trybem jest CBC.
Zakres tego wyniku trzeba określić dokładnie: opisuje on wbudowanego w JDK dostawcę SunJCE w podanej wyżej wersji. Domyślna transformacja jest decyzją dostawcy, więc dostawca zewnętrzny (na przykład BouncyCastle) może rozwinąć ten sam skrót inaczej. Uogólnienie nie brzmi „Java znaczy ECB”, tylko „niedookreślony ciąg transformacji znaczy tyle, ile zdecyduje dostawca, i dlatego takiego ciągu się nie pisze”.
Zły tryb wcale nie musi zgłosić błędu
W scenariuszu C szyfrogram CBC odszyfrowano przez AES/ECB i wrócił poprawny tekst jawny bez żadnego wyjątku. Wygląda to na niemożliwe, dopóki nie rozpisze się arytmetyki. Szyfrowanie CBC pierwszego bloku to C1 = E(P1 XOR IV), a deszyfrowanie ECB tego bloku to D(C1) = P1 XOR IV. IV składał się tutaj z samych zer, więc P1 XOR 0 = P1 i pierwszy blok odszyfrowuje się bezbłędnie. hello world ma długość jednego bloku, więc „pierwszy blok” był całą wiadomością.
Ogólna reguła brzmi tak: przy zerowym IV ECB i CBC zgadzają się na pierwszym bloku i rozjeżdżają na każdym następnym. Długa wiadomość CBC odszyfrowana jako ECB daje szesnaście czystych bajtów, po których idzie szum, czyli dokładną odwrotność odcisku palca złego IV. Dwa przeciwne kształty i w obu przypadkach żadnego komunikatu. Zaszyte w kodzie zerowe wektory IV zdarzają się na tyle często, że nie jest to laboratoryjna ciekawostka.
Co daje minimalne wywołanie w każdym języku
| Ekosystem | Minimalne wywołanie | Tryb, który faktycznie wychodzi |
|---|---|---|
| Java (SunJCE) | Cipher.getInstance("AES") | ECB z PKCS5Padding, po cichu |
Node crypto | createDecipheriv('aes-256-cbc', key, iv) | to, co mówi ciąg algorytmu; wartość domyślna nie istnieje |
| Web Crypto | crypto.subtle.decrypt({ name: 'AES-CBC', iv }, ...) | nazwany jawnie; ECB w ogóle nie jest zaimplementowany |
Python cryptography | Cipher(algorithms.AES(key), modes.CBC(iv)) | obiekt trybu jest obowiązkowy |
| PyCryptodome | AES.new(key, AES.MODE_ECB) | argument obowiązkowy, ale ECB czeka tuż obok w podpowiedziach |
Go crypto/aes | aes.NewCipher(key) zwraca surowy cipher.Block | wywołanie Decrypt na tym bloku to jest ECB; trzeba opakować je w cipher.NewCBCDecrypter albo cipher.NewGCM |
| CryptoJS | CryptoJS.AES.decrypt(ct, "passphrase") | CBC, PKCS#7, EVP_BytesToKey z MD5 (patrz sekcja 7) |
Ekosystemy, w których tryb siedzi w ciągu znaków albo w obiekcie, nigdy nie zaskakują. Dwa, które oferują wywołanie „po prostu AES”, czyli Java i Go, zbierają zgłoszenia o przypadkowym ECB. Kiedy podejrzenie pada na tryb, najszybciej rozstrzyga przełączenie trybu na tych samych bajtach.
6. GCM: te same bajty, inne API
Większość międzyjęzykowych awarii spod hasła aes gcm auth tag nie ma natury kryptograficznej. Obie strony policzyły te same 16 bajtów i różnią się co do tego, gdzie te bajty leżą.
Pomiar
Klucz = 32 bajty 0123456789abcdef0123456789abcdef, IV = 12 bajtów zerowych, tekst jawny hello world, na node v25.8.2 i java 1.8.0_162:
Node ciphertext = a616cd6d7d2328379d41e5 (11 B) <- update+final
authTag = c87af9f8ad7148e873fa797292c0af3f (16 B) <- pobierany osobno przez getAuthTag()
Java doFinal() = a616cd6d7d2328379d41e5c87af9f8ad7148e873fa797292c0af3f (27 B) <- szyfrogram i tag już sklejone
Node ciphertext || authTag to dokładnie Java doFinal(), wszystkie 27 bajtów. Żadnej różnicy w kodowaniu, nic do negocjowania: Node podaje dwa kawałki osobno, a Java oddaje je sklejone. Poza tym 11 bajtów tekstu jawnego dało 11 bajtów szyfrogramu, bo GCM nie dodaje dopełnienia. Dlatego błąd dopełnienia nigdy nie może pochodzić z prawdziwej ścieżki GCM.
Sklejone czy osobno, według środowiska
| Środowisko | API szyfrujące | Gdzie ląduje tag |
|---|---|---|
Node crypto | update() + final(), potem getAuthTag() | osobno |
Java (SunJCE, AES/GCM/NoPadding) | doFinal() | doklejony na końcu |
Go cipher.AEAD | Seal() | doklejony na końcu |
Python cryptography, AESGCM | encrypt() | doklejony na końcu |
Python cryptography, Cipher + modes.GCM | finalize(), potem encryptor.tag | osobno |
| Web Crypto | crypto.subtle.encrypt | doklejony na końcu |
Node jest wśród wysokopoziomowych API wyjątkiem i dlatego kierunek „z Node do czegokolwiek” to najczęściej zgłaszana awaria. Pakowanie wyjścia z Node dla konsumenta w Javie, Go, Pythonie albo w przeglądarce:
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const packed = Buffer.concat([ct, cipher.getAuthTag()]); // teraz zgadza się z doFinal()
Rozpakowanie sklejonego bloba dla Node:
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(packed.subarray(packed.length - 16)); // musi być przed final()
const pt = Buffer.concat([
decipher.update(packed.subarray(0, packed.length - 16)),
decipher.final(),
]);
Ograniczenie kolejności jest realne: wywołanie setAuthTag() po final() sprawia, że Node rzuca Unsupported state or unable to authenticate data, nawet gdy każdy bajt jest poprawny. Pozycję tagu w konkretnym blobie najlepiej sprawdzić na tych samych bajtach w trybie GCM.
Długość tagu jest zmienna, a jednostka niejednolita
GCM dopuszcza tagi o długości 128, 120, 112, 104 albo 96 bitów, a 64 i 32 są zarezerwowane dla zastosowań o ograniczonych zasobach (SP 800-38D, dodatek C). Prawie wszyscy używają 128 i cały kłopot polega na tym, jak każde API o tę wartość pyta:
- Java:
new GCMParameterSpec(128, iv). Pierwszy argument to bity. - Web Crypto:
{ name: 'AES-GCM', iv, tagLength: 128 }. Też bity, domyślnie 128. - Node:
createCipheriv(algo, key, iv, { authTagLength: 16 }). Bajty.
new GCMParameterSpec(16, iv) to poprawnie wyglądająca linijka Javy, która prosi o tag 16-bitowy; część JDK ją odrzuca, a tam, gdzie zostanie przyjęta, gwarancja integralności zamienia się w rzut monetą o szansie jeden na 65 536. Kiedy obie strony nie zgadzają się co do długości tagu, różnią się też długości spakowanych danych, więc odbiorca tnie w złym miejscu i dostaje błąd uwierzytelniania, który nie ma nic wspólnego z kluczem.
7. To passphrase, a nie klucz
Jeżeli którakolwiek ze stron przyjmuje ciąg wpisany przez człowieka, to między tym ciągiem a AES stoi funkcja wyprowadzania klucza, a niezgodność KDF jest niewidoczna. Nigdy nie zgłasza błędu. Zwraca 32 całkiem porządne bajty, które akurat są niewłaściwymi 32 bajtami, a awaria wychodzi na wierzch warstwę niżej jako, no właśnie, błąd dopełnienia.
PBKDF2 wymaga zgodności czterech rzeczy
- Sól. W formacie OpenSSL
Salted__to 8 bajtów wewnątrz szyfrogramu; w formacie passphrase naszych narzędzi to 16-bajtowy prefiks; w schematach pisanych na kolanie bywa najczęściej stałą zaszytą w kodzie. - Iteracje.
openssl enc -pbkdf2domyślnie wykonuje 10 000. OWASP zaleca obecnie 600 000 dla PBKDF2-HMAC-SHA256 i tyle właśnie ma nasz tryb passphrase. Frameworki dobierają własne liczby. - Hash. SHA-1 kontra SHA-256 kontra SHA-512. Starszy kod i część mobilnych SDK nadal domyślnie sięga po SHA-1.
- Długość wyjścia. Trzydzieści dwa bajty dla AES-256, szesnaście dla AES-128. Część schematów wyprowadza klucz i IV razem w jednym dłuższym wywołaniu, co nigdy nie zgodzi się ze zwykłym wyprowadzeniem 32 bajtów.
EVP_BytesToKey i dlaczego CryptoJS ciągle nie działa
Za hasłem cryptojs aes decrypt not working stoi zwykle jedna konkretna niezgodność. CryptoJS.AES.encrypt(text, "passphrase") nie używa PBKDF2. Używa EVP_BytesToKey, czyli wyprowadzania z OpenSSL sprzed 1.1, z MD5 i pojedynczą iteracją.
EVP_BytesToKey robi też coś, czego PBKDF2 nie robi: wyprowadza za jednym przebiegiem klucz i IV z passphrase oraz soli. Dlatego plik OpenSSL Salted__ nie niesie osobnego pola IV i dlatego odtwarzanie wyjścia CryptoJS przy pomocy PBKDF2 plus losowego IV jest błędne podwójnie.
Format rozpoznaje się na pierwszy rzut oka: 8 bajtów ASCII Salted__, po nich 8-bajtowa sól, a całość zakodowana w base64 zawsze zaczyna się od U2FsdGVkX1. Szyfrogram zaczynający się w ten sposób pochodzi z passphrase i trzeba wiedzieć, z którego wyprowadzenia; narzędzie do odszyfrowywania AES wykrywa ten prefiks i przełącza się między trzema wariantami bez zmian w kodzie.
Dlaczego to samo hasło daje różne klucze
Coś takiego jak „hasło AES” nie istnieje. Każda biblioteka wymyśliła własną drogę od ciągu znaków do klucza:
| Producent | Wyprowadzenie | Wynik dla jednego passphrase |
|---|---|---|
CryptoJS AES.encrypt(text, pass) | EVP_BytesToKey, MD5, 1 iteracja | klucz A |
openssl enc 1.0.2 i starsze | EVP_BytesToKey, MD5, 1 iteracja | klucz A |
openssl enc 1.1+ bez -pbkdf2 | EVP_BytesToKey, SHA-256, 1 iteracja | klucz B |
openssl enc -pbkdf2 | PBKDF2-HMAC-SHA256, 10 000 iteracji | klucz C |
| Nasz tryb passphrase | PBKDF2-HMAC-SHA256, 600 000 iteracji | klucz D |
| Java, Python, Go | brak jakiejkolwiek wartości domyślnej; wyprowadzenie pisze się samemu | to, co zostało napisane |
Cztery klucze z jednego hasła, zanim ktokolwiek popełnił błąd. Przejście z 1.0.2 na 1.1 zmieniło domyślny skrót z MD5 na SHA-256 i dlatego szyfrogram ze starych skryptów przestał się odszyfrowywać tym samym poleceniem na nowszej maszynie. Jeśli dane zostały odziedziczone i nikt nie pamięta zestawu narzędzi, wyprowadzenia próbuje się w tej właśnie kolejności. To test z trzema wariantami, a nie przeszukiwanie.
8. Co transport zrobił z bajtami
Szyfrogram to jednostajnie losowe dane binarne, więc źle znosi każdą warstwę, która traktuje bajty jak tekst. Spora część awarii AES w ogóle nie dotyczy samego szyfru.
Warianty Base64 i brakujące dopełnienie
Standardowe base64 (RFC 4648 §4) używa + i /; wariant bezpieczny dla URL (§5) używa - i _. Ciąg w wariancie URL podany standardowemu dekoderowi albo rzuca wyjątek, albo (w dekoderach pobłażliwych) po cichu wyrzuca problematyczne znaki i zwraca za krótkie, przesunięte bajty. Dlatego Java dostarcza Base64.getUrlDecoder() i Base64.getDecoder() jako osobne obiekty. Część koderów usuwa też końcowe =, część dekoderów się go domaga, a ścieżki kodu związane z JWT domyślnie go obcinają.
Zanim podejrzenie padnie na klucz, warto zdekodować szyfrogram i zestawić jego długość z trybem:
- CBC i ECB: niezerowa wielokrotność 16. Wszystko inne to obcięcie albo problem z dekodowaniem, a nie problem z kluczem.
- GCM: długość szyfrogramu równa się długości tekstu jawnego, plus 16 na tag, plus 12 z przodu, jeśli nonce jest doklejony na początku.
- CTR: dowolna długość, więc ten test nie mówi nic.
Dekoder Base64 podaje liczbę bajtów po jednym wklejeniu, często najszybszy pomiar w całym śledztwie.
Znaki nowej linii, cudzysłowy typograficzne i cykl konwersji przez UTF-8
openssl base64 łamie wyjście co 64 kolumny, o ile nie poda się -A. Jedne dekodery pomijają osadzone znaki nowej linii, inne je odrzucają, więc ten sam plik dekoduje się na jednej maszynie, a na drugiej nie. Kopiowanie przez komunikator albo edytor tekstu zamienia proste cudzysłowy na typograficzne, a łączniki na półpauzy, przy czym różnica jest w terminalu prawie niewidoczna.
Nie do odratowania jest cykl konwersji przez UTF-8. Jeżeli surowe wyjście AES trafi kiedykolwiek do zmiennej tekstowej bez wcześniejszego zakodowania (new String(cipherBytes) w Javie, bytes.decode('utf-8', errors='replace') w Pythonie, TextDecoder gdziekolwiek), każda sekwencja bajtów, która nie jest poprawnym UTF-8, zapada się do U+FFFD, a zakodowanie z powrotem daje EF BF BD w miejscu, gdzie były dane. Ponieważ mniej więcej połowa losowych bajtów leży poza ASCII, większość szyfrogramu ulega zniszczeniu i żaden klucz jej nie odzyska; przewodnik po kodowaniu UTF-8 i UTF-16 tłumaczy, dlaczego ta strata jest jednokierunkowa. Binarny szyfrogram podróżuje jako base64, jako hex albo jako dane binarne, ale nigdy jako ciąg znaków.
Kolumny w bazie danych
Warstwa składowania wyrządza te same szkody, tylko ciszej. Szyfrogram zapisany do VARCHAR(255) i dłuższy o jeden blok zostaje przycięty, a MySQL poza trybem ścisłym robi to bez żadnego błędu. Na końcu siedzą blok dopełnienia i tag GCM, więc wiersz zapisany „pomyślnie” kilka miesięcy temu dziś się nie odszyfrowuje. Jeśli cięcie wypadło na granicy 16 bajtów, powyższy test długości też tego nie wychwyci. Resztę załatwia konwersja zestawu znaków: kolumna latin1 przyjmująca bajty UTF-8 przepisuje dane już na wejściu.
Szyfrogram należy trzymać w VARBINARY, BLOB albo bytea, ewentualnie zapisywać base64 w kolumnie tekstowej z zapasem miejsca.
9. Bisekcja, która znajduje przyczynę w pięć minut
Każda z powyższych sekcji zawęża jedną zmienną. Jeśli przejść je po kolei względem własnej, kontrolowanej implementacji odniesienia, wynik zbiega szybko. Narzędzia działające w przeglądarce sprawdzają się w roli takiego odniesienia, bo pozwalają zmieniać po jednym ustawieniu naraz i oglądać bajty; liczą wszystko lokalnie, więc klucz ani szyfrogram nie opuszczają strony i nic nie trafia na serwer.
-
Krok 0. Zmierz kształt. Zdekoduj szyfrogram i zanotuj liczbę bajtów, kilka pierwszych bajtów oraz to, czy zaczyna się od
U2FsdGVkX1. Zestaw tę liczbę z sekcją 8. Jeśli nie jest wielokrotnością 16, a wszystko wskazuje na CBC, to koniec: błąd leży w transporcie. -
Krok 1. Zaszyfruj znany tekst jawny. W narzędziu do szyfrowania AES zaszyfruj krótki, znany ciąg parametrami, które rzekomo obowiązują na produkcji, a potem porównaj kształt obu wyjść zamiast ich wartości: łączną długość, bajty prefiksu, obecność nagłówka soli. Rozjazd oznacza, że założenie co do formatu albo KDF jest błędne i żadne grzebanie przy kluczu tego nie naprawi.
-
Krok 2. Przejdź przez wyprowadzenia. Dla danych wyprowadzonych z passphrase uruchom w narzędziu do odszyfrowywania AES najpierw PBKDF2 z dokładną liczbą iteracji, potem EVP-SHA256, potem EVP-MD5. Poprawne może być dokładnie jedno. Jeśli żadne nie zadziała, błąd leży powyżej KDF.
-
Krok 3. Usuń wszystkie konwencje. Przełącz się na surowy klucz, włącz surowy szyfrogram, podaj IV jawnie. Teraz jest wprost powiedziane, które bajty są kluczem, które wektorem IV, a które szyfrogramem; nic nie jest dorozumiane. Jeśli tutaj się odszyfrowuje, a w kodzie nie, to błąd dotyczy opakowania danych (nieusunięty prefiks IV, tag w złym miejscu), a nie kryptografii.
-
Krok 4. Przełącz tryb. Wypróbuj na tych samych bajtach CBC, potem CTR, potem GCM. Jeśli CTR zwraca czytelny tekst tam, gdzie CBC zawiodło, to niezgodność trybu i koniec dyskusji.
-
Krok 5. Przeczytaj śmieci. Pierwszy blok zepsuty, reszta czysta: winny jest IV. Pierwszy blok czysty, reszta zepsuta: szyfrogram CBC został odszyfrowany jako ECB przy zerowym IV. Wszystko zepsute: winny jest klucz albo wyprowadzenie.
10. Najczęstsze pytania
Dlaczego kod AES działa lokalnie, a na produkcji nie?
Między maszyną lokalną a produkcją zmieniło się coś, czego nie ma w repozytorium. Typowi podejrzani, po kolei: klucz przyszedł ze zmiennej środowiskowej albo z menedżera sekretów ze znakiem nowej linii na końcu; domyślne kodowanie platformy w Javie różni się między laptopem a kontenerem, więc getBytes() wyprodukowało inne bajty (sekcja 3); na produkcji stoi OpenSSL 1.1+, a lokalne skrypty pisano pod 1.0.2, co zmienia skrót w EVP_BytesToKey z MD5 na SHA-256; albo kolumna bazy danych obcina szyfrogram tylko w jednym środowisku. Zacznij od wypisania po obu stronach długości klucza i długości szyfrogramu w bajtach; te dwie liczby zwykle rozstrzygają sprawę.
Zaszyfrowane w Node, nie da się odszyfrować w Javie. Od czego zacząć?
Od tagu GCM: w kierunku z Node do Javy to najczęstsza i najmniej oczywista przyczyna. Node zwraca szyfrogram i tag osobno; doFinal() w Javie oczekuje ich sklejonych jako ciphertext || tag, a sekcja 6 pokazuje, że poza tym bajty są identyczne. Jeśli w grze jest CBC, zacząć trzeba od konwencji IV: czy Node doklejał go z przodu i czy strona javowa odcina 16 bajtów przed deszyfrowaniem? Trzeci w kolejności jest sam klucz: Buffer.from(k, 'hex') i k.getBytes(StandardCharsets.UTF_8) dają z tego samego ciągu różne długości.
Czy PKCS5Padding w Javie to to samo co PKCS#7?
Dla AES PKCS5Padding w Javie to w praktyce PKCS#7. PKCS#5 (RFC 8018) jest zdefiniowany wyłącznie dla bloków 8-bajtowych; PKCS#7 (RFC 5652) uogólnia ten schemat na rozmiary bloku od 1 do 255 bajtów. PKCS5Padding w Javie zastosowany do szyfru blokowego o bloku 16-bajtowym realizuje zachowanie PKCS#7, a sama nazwa jest historyczną pozostałością, więc tutaj nigdy nie leży błąd. Leży za to w NoPadding: ten tryb wymaga tekstu jawnego będącego już wielokrotnością 16, a przy deszyfrowaniu oddaje dopełnienie jako dane, więc widać wiarygodny tekst z doklejonymi na końcu bajtami w rodzaju \x05\x05\x05\x05\x05.
Klucz ma 32 znaki, a AES zgłasza nieprawidłową długość. Dlaczego?
Błąd długości oznacza, że biblioteka dostała liczbę bajtów inną niż 16, 24 albo 32. Przy 32-znakowym ciągu to zwykle znak nowej linii na końcu (33 bajty), prefiks 0x, który psuje poprawność hexa, albo znak spoza ASCII zajmujący w UTF-8 dwa lub trzy bajty. Groźniejszy wariant to brak jakiegokolwiek błędu: 32 znaki hex dekodują się do 16 poprawnych bajtów, a 32 znaki base64 do 24 poprawnych bajtów, a obie te długości są dla AES dozwolone. Biblioteka je przyjmie, użyje złego klucza i zamiast tego zwróci błąd dopełnienia. Sprawdzać należy liczbę bajtów, a nie liczbę znaków.
Deszyfrowanie „się udało”, ale wynik to śmieci. Co poszło nie tak?
Śmieci na wyjściu oznaczają tryb, który niczego nie weryfikuje. CTR i ECB nigdy nie rzucają wyjątku, a CBC rzuca tylko wtedy, gdy końcowy wzorzec bajtów nie przejdzie kontroli dopełnienia, co przy złym kluczu zdarza się w niecałych 0,4% przypadków. Wystarczy odczytać kształt: pierwsze 16 bajtów zepsute, a reszta czysta wskazuje na IV; pierwsze 16 czyste, a reszta zepsuta oznacza szyfrogram CBC odszyfrowany jako ECB przy zerowym IV; wszystko równomiernie zepsute obciąża klucz albo wyprowadzenie. Czytelny tekst z kilkoma dziwnymi bajtami na końcu to NoPadding na dopełnionych danych. Docelowa naprawa to GCM, żeby „udało się” cokolwiek znaczyło.
Czy da się odszyfrować dane po utracie IV?
Bez IV w CBC nadal da się odszyfrować wszystko poza pierwszymi 16 bajtami. Bloki od drugiego w górę odzyskuje się jako D(C_i) XOR C_{i-1}, a każde wejście do tego działania jest już w szyfrogramie, więc IV potrzebuje wyłącznie pierwszy blok. Jeżeli dodatkowo wiadomo, jak zaczyna się tekst jawny (powiedzmy blob JSON zaczynający się od {"userId":), IV daje się odzyskać wprost jako D(C1) XOR P1. W CTR IV zasiewa cały strumień klucza, więc jego utrata to utrata wszystkiego. W GCM nonce zasila i licznik, i tag, więc częściowe odzyskanie nie wchodzi w grę.
Czy da się odzyskać tekst jawny, gdy tag GCM został obcięty albo zgubiony?
Obcięty albo zgubiony tag GCM nie przekreśla tekstu jawnego: matematycznie da się go odzyskać, praktycznie kosztem wysiłku. GCM to pod spodem tryb licznikowy CTR, więc sam klucz i nonce odtwarzają strumień klucza. Żadna popularna biblioteka tego nie zrobi: Java, Go, Python i Web Crypto z założenia odmawiają wydania tekstu jawnego bez poprawnego tagu. Obejście polega na odszyfrowaniu tych samych bajtów jako AES-CTR z początkowym blokiem licznika ustawionym na 12-bajtowy nonce, po którym idzie 00000002; właśnie tam zaczyna się w GCM pierwszy blok danych. Dane wracają kosztem rezygnacji z wszelkich gwarancji integralności, więc wynik należy traktować jako niezaufany. Jeśli wszystkie 16 bajtów tagu jest na miejscu, a uwierzytelnianie i tak zawodzi, to tag nie zaginął i błąd jest gdzie indziej na tej stronie. Wtedy najlepiej wrócić do narzędzia do odszyfrowywania AES i zacząć od kroku 0.