Ошибка расшифровки AES: ключ, IV, режим и дополнение
Когда в логах появляется AES decryption failed, само сообщение почти наверняка описывает не ту проблему. Четыре несвязанных бага дают практически одинаковые симптомы, а самый частый из них — неверный ключ — объявляет о себе как ошибка дополнения (padding).
Если расшифровка CBC падает с BadPaddingException, больше всего времени экономит такой порядок проверки:
- Байты ключа различаются на двух сторонах. С большим отрывом самая вероятная причина.
- Ключ выводится по-разному: одна и та же passphrase, но другая KDF или другое число итераций — значит, другие байты ключа.
- Шифротекст повредился при передаче: обрезан, испорчен при декодировании base64 или прогнан через текстовую кодировку.
- Неверный вектор инициализации (IV). Причина реальная, но ошибку дополнения она не вызывает: портит шестнадцать байт и молчит.
Порядок здесь не вкусовой, а структурный. CBC проверяет дополнение последним шагом расшифровки — уже после того, как применен ключ и размотана цепочка. Дополнение поэтому работает как контрольная сумма для всего, что было выше по потоку, и падает громко независимо от того, что именно наверху сломалось. Замеры ниже разбирают каждую ветку по отдельности; можно и не читать их подряд, а сразу вставить свой шифротекст в инструмент расшифровки AES и прогнать бисекцию из раздела 9.
Всё, что ниже, замерено на java 1.8.0_162, node v25.8.2 и openssl 3.6.2. Значения по умолчанию меняются от версии к версии, так что номера версий — часть результата.
1. Начните с того, что ошибка исключает
Сообщение о сбое AES почти ничего не говорит о причине и довольно много — о том, какой причина быть не может. Используйте его, чтобы вычеркивать ветки, а не чтобы выбирать одну.
| Что вы видите | Что это исключает | Что остается в игре |
|---|---|---|
BadPaddingException, bad decrypt, wrong final block length | GCM; чистую ошибку в IV; сбой декодирования | неверный ключ, неверная KDF, обрезанный шифротекст, байты IV, съеденные как шифротекст, несовпадение режимов, несовпадение схем дополнения |
GCM Authentication failed, Unsupported state or unable to authenticate data | дополнение; любую гипотезу с частичным выводом | неверный ключ, неверный nonce, отделенный или положенный не туда тег, неверная длина тега, несовпадение AAD |
| Исключения нет, на выходе мусор | все аутентифицированные режимы | ECB, CTR, случайно уцелевший CBC, несовпадение режимов, неверный IV |
BadPaddingException, bad decrypt, wrong final block length
Одно и то же событие в трех экосистемах: Java, OpenSSL и .NET. Оно срабатывает в конце расшифровки CBC или ECB, когда последний блок открытого текста не заканчивается корректным шаблоном PKCS#7.
Полезна здесь отрицательная часть: раз дело дошло досюда, значит, ваш base64 или hex декодировался, а число байт оказалось ненулевым кратным 16 — транспорт данные не порвал, и вы не в GCM. Исключение — wrong final block length. Там число байт кратным 16 не было, а это указывает на обрезку, а не на ключ, так что переходите сразу к разделу 8.
GCM Authentication failed и его родня
GCM сверяет тег до того, как отдать хоть один байт открытого текста, — так требует NIST SP 800-38D. Это делает его честным в том смысле, в каком ошибка дополнения нечестна: что-то в наборе (ключ, nonce, шифротекст, дополнительные аутентифицируемые данные, тег) не совпадает с тем, что было у шифрующей стороны. Какой именно, он не скажет: сужать этот выбор алгоритм намеренно не умеет. Раздел 6 разбирает то, что чаще всего ломается при переходе между языками: позицию тега, а не его значение.
Ошибки нет, но на выходе мусор
Это самый опасный исход: дашборд записывает его как успех. CTR не бросает исключений никогда, ECB — тоже. CBC бросает только тогда, когда финальный байтовый шаблон не проходит проверку дополнения, а с неверным ключом этот байт фактически случаен, так что примерно одна попытка из 256 попадает в 0x01 и проходит проверку. Чуть меньше 0,4% расшифровок CBC с неверным ключом «удаются». Но у мусора есть форма, и форма называет баг: в разделах 4 и 5 описаны два отпечатка, которые стоит запомнить.
2. Самая обманчивая ошибка в AES
Этот замер переставляет приоритеты отладки у большинства людей. Ключ 0123456789abcdef, нулевой IV, AES/CBC/PKCS5Padding, открытый текст hello world, на java 1.8.0_162 со встроенным в JDK провайдером SunJCE:
| Сценарий | Изменение | Измеренный результат |
|---|---|---|
| A | Ключ неверен на 1 байт (последний символ f → X) | бросает javax.crypto.BadPaddingException: Given final block not properly padded. Само дополнение при этом не было испорчено ни разу; ошибка полностью вводит в заблуждение |
| B | Ключ верен, IV неверен на 1 байт | исключения нет, открытый текст hello world вернулся как iello world. Поврежден только соответствующий байт первого блока |
| C | Ключ верен, шифротекст CBC расшифрован как AES/ECB | молча удалось, исключения нет. Несовпадение режимов вовсе не обязано что-то выбрасывать |
Сценарий A съедает целые вечера. Сценарий C отправляет плохие данные в production.
Почему неверный ключ даёт ошибку дополнения
С дополнением всё было в порядке. Шифрующая сторона дописала к hello world пять байт 0x05, чтобы добить блок до шестнадцати, зашифровала этот блок, и он лежит в вашем шифротексте целым и невредимым.
Сбой происходит на обратном пути. Расшифровка CBC прогоняет блочный шифр в обратную сторону, ксорит каждый результат с предыдущим блоком шифротекста и только потом читает хвост последнего блока, чтобы решить, сколько байт отрезать. С неверным ключом шифр выдает шестнадцать байт шума, а шум почти никогда не заканчивается корректным шаблоном PKCS#7. Библиотека сообщает о том, что увидела: bad padding. Сообщение правдивое и совершенно бесполезное.
Читайте BadPaddingException как «восстановленный мною открытый текст заканчивается не так, как заканчивается дополненный открытый текст». Самая вероятная причина того, что восстановление неверно, — ключ. Поэтому поиск по aes decrypt wrong key и поиск по bad padding exception приводят в одни и те же треды: два симптома оказываются одним симптомом. Заодно замечание по проектированию: никогда не показывайте эту разницу вызывающей стороне. Возможность отличить «дополнение некорректно» от «дополнение корректно, содержимое не то» — как раз то, чем питается атака padding oracle (Vaudenay, EUROCRYPT 2002).
Что GCM делает иначе
GCM меняет порядок на обратный: тег проверяется до того, как появится хоть какой-то открытый текст, и окна, в котором существуют частично верные байты, просто нет. После сбоя GCM не остается сомнений, настоящий ли перед вами вывод, потому что вывода нет вообще. Дополнения у GCM тоже нет никакого — внутри это режим счетчика, — так что длина шифротекста равна длине открытого текста. Значит, ошибка дополнения в системе, которую вы считали GCM, доказывает, что система не GCM: обычно это конфигурация, соскользнувшая обратно на CBC.
3. Одни ли байты ключа с обеих сторон?
AES не видит вашей строки-ключа. Он видит 16, 24 или 32 байта. Две системы могут держать в конфиге одинаковый ключевой материал и всё равно расходиться, потому что «одинаковый» — свойство текста, а не байтов.
Три способа превратить строку ключа в байты
Отдайте буквальную строку 0123456789abcdef трем разным библиотекам:
as hex -> 8 bytes (invalid AES key length)
as base64 -> 12 bytes (invalid AES key length)
as raw UTF-8 -> 16 bytes (valid AES-128)
Шестнадцать символов, три разных числа байт. Случай мерзкий именно потому, что строка допустима при всех трех прочтениях: каждый символ входит и в алфавит hex, и в алфавит base64, а шестнадцать символов — законная длина для обоих декодеров, так что на этапе разбора не падает ничего.
В гиде по ошибке invalid signature в JWT есть полная межбиблиотечная матрица того, как каждая экосистема трактует строку секрета; краткая версия для AES — записать, в какой кодировке лежит ваш ключевой материал, и заставить обе стороны декодировать его явно. В обличье HMAC тот же баг кусает приемники webhook; его разбирает руководство по ошибкам проверки подписи webhook.
AES строг: ровно 16, 24 или 32 байта
Здесь AES отличается от примитива, с которым большинство разработчиков знакомится раньше. HMAC принимает ключ любой длины: RFC 2104 вычисляет хеш от всего, что длиннее размера блока, и дополняет нулями всё, что короче, так что генератор HMAC без единой жалобы съест и 7-байтовый, и 700-байтовый секрет. У AES ровно три законные длины ключа, и всё остальное он отвергает до того, как обработан хотя бы один блок.
Эта строгость — подарок, потому что ошибка длины оказывается единственным сбоем AES, который называет собственную причину, а не прячется за дополнением. Наш инструмент формулирует ее так: Key must be 16, 24, or 32 bytes (AES-128/192/256). Ловушки, дающие неверную длину:
- Завершающий перевод строки от
KEY=$(cat key.txt)илиecho "$KEY". Используйтеprintfиecho -n. Пробел в конце, притащенный из интерфейса менеджера секретов, делает ровно то же самое. - Префикс
0x, скопированный из отладчика: тридцать четыре символа, которые уже не являются корректным hex. - Не-ASCII символы:
contraseña— это 10 символов и 11 байт в UTF-8, так что «32-символьная» passphrase с одной буквой под диакритикой весит 33 байта.
SecretKeySpec и кодировка платформы по умолчанию
У Java есть версия этой проблемы, которая проявляется только после развертывания. "my secret".getBytes() без аргумента использует кодировку платформы по умолчанию, а она до JDK 18 бралась из свойства file.encoding, то есть из операционной системы и локали машины. Ноутбук на UTF-8 и контейнер на ANSI_X3.4-1968 дают разные байты для любого не-ASCII символа. JEP 400 сделал UTF-8 умолчанием в JDK 18, и это чинит новый код — и больше ничего.
// неверно: байты зависят от машины
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(), "AES");
// верно: байты не зависят ни от чего
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "AES");
Если ваш код работает локально, падает на сервере с ошибкой дополнения, а в passphrase есть что-нибудь за пределами ASCII, проверяйте это первым делом.
4. IV: куда он кладётся и как выглядит неверный
Несовпадение IV (aes iv mismatch) — сбой, который подозревают первым, а диагностируют последним, потому что ведет он себя не как остальные. Он тихий и локальный.
Неверный IV портит ровно один блок
Посмотрите еще раз на сценарий B. Ключ верен, IV неверен на один байт:
hello world -> iello world
Исключения нет, испорчен один символ. Выпишите шаг CBC для первого блока, и всё станет очевидно: P1 = D(C1) XOR IV. IV ксорится прямо в первый блок открытого текста и больше ни на что не влияет, так что переворот одного бита в IV переворачивает тот же бит открытого текста в той же позиции. Здесь h (0x68) стал i (0x69), значит, первый байт IV сдвинулся ровно на 0x01.
В CBC мусор в первых 16 байтах и чистые данные после них означают, что IV неверный, а ключ верный. Мусор в каждом блоке означает, что неверен ключ. Одно это наблюдение разделяет две самые частые причины, не требуя менять ни строчки кода, а инструмент расшифровки AES показывает декодированные байты, так что прочитать отпечаток можно прямо там.
Ничего не выбросилось потому, что hello world — это 11 байт, то есть один блок, и дополнение PKCS#7 живет в его байтах с 11-го по 15-й. Изменился байт IV с номером 0, так что область дополнения осталась нетронутой и прошла проверку. Испортите байт IV на позиции 11 или дальше — и получите ошибку дополнения, а это еще один путь, которым ошибка дополнения вам врет.
Три соглашения о передаче
Стандарта на то, где едет IV, нет — есть три привычки, которые плохо стыкуются между собой.
Чаще всего IV приписывают спереди: iv || ciphertext. Это же вариант по умолчанию в наших инструментах. Обе стороны должны договориться, сколько отрезать: 16 байт для CBC и CTR, 12 для GCM. Зеркальный баг — когда отправитель приписывает IV спереди, а получатель этого не ждет. Первые 16 байт «шифротекста» тогда оказываются вектором инициализации, каждый блок съезжает, и вы получаете ошибку дополнения.
Отдельное поле, {"iv": "...", "ciphertext": "..."}, в принципе чище и заодно удваивает число мест, где кодирование может разойтись: у IV теперь свой собственный вопрос «base64 или hex».
Третий вариант — фиксированная константа, обычно из одних нулей, зашитая в код, потому что кому-то понадобился детерминизм. Стыкуется она идеально, и именно это делает ее опасной: в CBC фиксированный IV раскрывает равенство записей между собой, а в GCM повторное использование nonce под одним ключом выдает XOR двух открытых текстов и может раскрыть подключ GHASH, которым аутентифицируется тег. SP 800-38D говорит про уникальность прямым текстом.
Переключатель «голый шифротекст» вместе с явным заданием IV позволяет проверить все три соглашения на одних и тех же байтах.
IV в GCM — 12 байт, а не 16
Команды, которые переходят на GCM правкой существующего пути CBC, переносят туда 16-байтовый IV, и результат падает без единой подсказки.
SP 800-38D стандартизирует 96-битный IV. Другие длины разрешены, но это не просто «IV подлиннее»: когда IV не равен 96 битам, GCM выводит начальный блок счетчика, прогоняя IV через GHASH, вместо того чтобы использовать его напрямую. Те же 16 байт в роли nonce дают поэтому совершенно другой ключевой поток и другой тег, чем дали бы первые 12, а вы получаете общую ошибку аутентификации. Если шифротекст пришел со стороны и вы гадаете о раскладке, считайте с конца: тег — это последние 16 байт, а nonce почти всегда первые 12.
5. Несовпадение режимов, в том числе молчаливое
Cipher.getInstance("AES") — это ECB
Java позволяет назвать шифр, не называя ни режим, ни схему дополнения. Она не отказывает и не предупреждает. Со встроенным в JDK провайдером SunJCE пропуски заполняются как ECB и PKCS5Padding.
Чтобы это доказать, нужен правильный эксперимент: зашифровать 32 одинаковых байта (два блока из A) ключом 0123456789abcdef, а затем проверить, совпадают ли два блока шифротекста. На java 1.8.0_162:
getInstance("AES") ciphertext = 3bfd04cc0d7ed55358e2cbe19de213833bfd04cc0d7ed55358e2cbe19de21383377222e061a924c591cd9c27ea163ed4
block1 = 3bfd04cc0d7ed55358e2cbe19de21383
block2 = 3bfd04cc0d7ed55358e2cbe19de21383 <- identical blocks = the ECB fingerprint (plaintext structure leaks)
getInstance("AES/CBC/PKCS5Padding") blocks differ = chaining is active
Байт в байт одинаковые. Это и есть подпись ECB — то самое свойство, из-за которого знаменитый зашифрованный пингвин на картинке по-прежнему выглядит пингвином. Эксперимент работает только на одинаковых блоках открытого текста: шестнадцать байт A, за которыми идут шестнадцать байт B, дадут два разных блока шифротекста и под ECB тоже, и вы ошибочно заключите, что по умолчанию был CBC.
У результата узкая область применимости: он описывает встроенный в JDK провайдер SunJCE на указанной выше версии. Преобразование по умолчанию — решение провайдера, так что сторонний провайдер вроде BouncyCastle может раскрыть то же сокращение иначе. Обобщение звучит не как «Java означает ECB», а как «неполная строка преобразования означает то, что решит ваш провайдер, — и именно поэтому такую строку никогда не пишут». В проектах, где рядом с AES стоит блочный шифр ГОСТ, конфигурацию перепутать еще легче: режим и дополнение приходится задавать каждому алгоритму отдельно. По-русски одно и то же действие называют и расшифровкой, и расшифрованием, и дешифрованием, а в логах оно всё равно приходит английской строкой, так что искать причину приходится по английскому тексту ошибки.
Неверный режим может не выдать ошибку
В сценарии C шифротекст CBC был расшифрован как AES/ECB и вернул верный открытый текст без исключения. Выглядит невозможным, пока не выпишешь арифметику. Шифрование первого блока в CBC — это C1 = E(P1 XOR IV), а расшифровка того же блока в ECB — D(C1) = P1 XOR IV. IV здесь был из одних нулей, так что P1 XOR 0 = P1, и первый блок расшифровывается идеально. hello world длиной ровно в один блок, так что «первый блок» и был всем сообщением.
Общее правило: при нулевом IV ECB и CBC сходятся на первом блоке и расходятся на каждом следующем. Расшифруйте длинное сообщение CBC как ECB — получите шестнадцать чистых байт, за которыми идет шум, то есть точную инверсию отпечатка неверного IV. Две противоположные формы, два разных бага, и ни в одном случае никакого сообщения об ошибке. Зашитые в код нулевые IV встречаются достаточно часто, чтобы это не было лабораторной диковинкой.
Что даёт минимальный вызов в каждом языке
| Экосистема | Минимальный вызов | Режим, который вы получаете на самом деле |
|---|---|---|
| Java (SunJCE) | Cipher.getInstance("AES") | ECB с PKCS5Padding, молча |
Node crypto | createDecipheriv('aes-256-cbc', key, iv) | то, что написано в строке алгоритма; умолчания не существует |
| Web Crypto | crypto.subtle.decrypt({ name: 'AES-CBC', iv }, ...) | назван явно; ECB не реализован вообще |
Python cryptography | Cipher(algorithms.AES(key), modes.CBC(iv)) | объект режима обязателен |
| PyCryptodome | AES.new(key, AES.MODE_ECB) | обязательный аргумент, но ECB лежит прямо там, в автодополнении |
Go crypto/aes | aes.NewCipher(key) возвращает сырой cipher.Block | вызов Decrypt на этом блоке и есть ECB; оберните его в cipher.NewCBCDecrypter или cipher.NewGCM |
| CryptoJS | CryptoJS.AES.decrypt(ct, "passphrase") | CBC, PKCS#7, EVP_BytesToKey с MD5 (см. раздел 7) |
Экосистемы, где режим живет в строке или в объекте, сюрпризов не преподносят. Два языка, предлагающие вызов «просто AES», — Java и Go, — и есть источник сообщений о случайном ECB. Если по коду не понять, каким режимом получен шифротекст, проверьте режимы по очереди, пока один из них не вернет читаемый текст.
6. GCM: те же байты, разные API
Большинство межъязыковых сбоев с тегом аутентификации GCM (aes gcm auth tag) не имеют отношения к криптографии. Обе стороны вычислили одни и те же 16 байт и расходятся в том, где эти байты лежат.
Замер
Ключ — 32 байта 0123456789abcdef0123456789abcdef, IV — 12 нулевых байт, открытый текст hello world, на node v25.8.2 и java 1.8.0_162:
Node ciphertext = a616cd6d7d2328379d41e5 (11 B) <- update+final
authTag = c87af9f8ad7148e873fa797292c0af3f (16 B) <- fetched separately via getAuthTag()
Java doFinal() = a616cd6d7d2328379d41e5c87af9f8ad7148e873fa797292c0af3f (27 B) <- ciphertext and tag already concatenated
Node ciphertext || authTag — это в точности Java doFinal(), все 27 байт целиком. Никакой разницы в кодировании, договариваться не о чем: Node отдает вам два куска по отдельности, а Java отдает их склеенными. И 11 байт открытого текста дали ровно 11 байт шифротекста, потому что GCM не добавляет дополнения. Вот почему ошибка дополнения не может прийти из настоящего пути GCM.
Склеенный или отдельный тег, по средам исполнения
| Среда исполнения | API шифрования | Где оказывается тег |
|---|---|---|
Node crypto | update() + final(), затем getAuthTag() | отдельно |
Java (SunJCE, AES/GCM/NoPadding) | doFinal() | приписан в конец |
Go cipher.AEAD | Seal() | приписан в конец |
Python cryptography, AESGCM | encrypt() | приписан в конец |
Python cryptography, Cipher + modes.GCM | finalize(), затем encryptor.tag | отдельно |
| Web Crypto | crypto.subtle.encrypt | приписан в конец |
Среди высокоуровневых API Node — белая ворона, и поэтому направление «из Node куда угодно» лидирует по числу сообщений о сбоях. Когда блоб достался вам по наследству и соглашение по нему не восстановить, посмотрите, лежит ли тег в последних 16 байтах, прежде чем трогать ключ. Упаковка вывода Node для потребителя на Java, Go, Python или в браузере:
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()]); // теперь совпадает с doFinal()
Распаковка склеенного блоба для Node:
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(packed.subarray(packed.length - 16)); // должно идти до final()
const pt = Buffer.concat([
decipher.update(packed.subarray(0, packed.length - 16)),
decipher.final(),
]);
Ограничение на порядок вызовов совершенно реальное: вызовите setAuthTag() после final(), и Node бросит Unsupported state or unable to authenticate data, даже если все байты верны.
Длина тега переменная, а единица измерения — нет
GCM разрешает теги длиной 128, 120, 112, 104 или 96 бит, а 64 и 32 зарезервированы для устройств с жесткими ограничениями (SP 800-38D, приложение C). Почти все используют 128, а беда в том, как именно каждый API эту длину спрашивает:
- Java:
new GCMParameterSpec(128, iv), первый аргумент задается в битах. - Web Crypto:
{ name: 'AES-GCM', iv, tagLength: 128 }, тоже биты, по умолчанию 128. - Node:
createCipheriv(algo, key, iv, { authTagLength: 16 }), а здесь байты.
new GCMParameterSpec(16, iv) — законно выглядящая строка на Java, которая просит 16-битный тег; некоторые JDK ее отвергают, а там, где она принимается, вы обменяли гарантию целостности на подбрасывание монетки с шансом один к 65 536. Когда стороны расходятся в длине тега, расходятся и длины упакованных данных, так что получатель режет по неверной границе и получает ошибку аутентификации, не имеющую к ключу никакого отношения.
7. У вас passphrase, а не ключ
Если хоть одна сторона принимает строку, набранную человеком, то между этой строкой и AES стоит функция получения ключа, и несовпадение KDF невидимо. Оно никогда не выдает ошибку, а просто возвращает 32 совершенно нормальных байта, которые случайно оказываются не теми 32 байтами, и сбой всплывает уровнем ниже как ошибка дополнения.
В PBKDF2 должны совпасть четыре вещи
- Соль: в формате OpenSSL
Salted__это 8 байт внутри шифротекста; в формате passphrase наших инструментов это 16-байтовый префикс; в самописных схемах это сплошь и рядом зашитая константа. - Итерации: у
openssl enc -pbkdf2по умолчанию 10 000. OWASP сейчас рекомендует 600 000 для PBKDF2-HMAC-SHA256 — столько и использует наш режим passphrase. Фреймворки выбирают свои числа. - Хеш: SHA-1 против SHA-256 против SHA-512. Старый код и некоторые мобильные SDK до сих пор по умолчанию берут SHA-1.
- Длина вывода: тридцать два байта для AES-256, шестнадцать для AES-128. Некоторые схемы выводят ключ и IV вместе, одним удлиненным вызовом, и это никогда не совпадет с обычным выводом 32 байт.
EVP_BytesToKey и почему CryptoJS всё никак не работает
За запросом cryptojs aes decrypt not working обычно стоит одно конкретное расхождение. CryptoJS.AES.encrypt(text, "passphrase") не использует PBKDF2. Он использует EVP_BytesToKey — схему вывода из OpenSSL до версии 1.1 — с MD5 и одной итерацией.
EVP_BytesToKey делает и то, чего PBKDF2 не делает: за один проход он выводит из passphrase и соли ключ и IV. Поэтому в файле OpenSSL Salted__ нет отдельного поля для IV, и поэтому попытка воспроизвести вывод CryptoJS через PBKDF2 плюс случайный IV неверна дважды.
Формат узнается с первого взгляда: 8 ASCII-байт Salted__, за ними 8 байт соли; в кодировке base64 это всегда начинается с U2FsdGVkX1. Если ваш шифротекст начинается так, он выведен из passphrase, и вам нужно знать, какой именно схемой вывода; инструмент расшифровки AES распознает префикс и переключается между тремя вариантами без правок кода.
Почему один пароль даёт разные ключи
Такой вещи, как «пароль AES», не существует. Каждая библиотека изобрела свой путь от строки к ключу:
| Кто зашифровал | Схема вывода | Результат для одной passphrase |
|---|---|---|
CryptoJS AES.encrypt(text, pass) | EVP_BytesToKey, MD5, 1 итерация | ключ A |
openssl enc 1.0.2 и раньше | EVP_BytesToKey, MD5, 1 итерация | ключ A |
openssl enc 1.1+ без -pbkdf2 | EVP_BytesToKey, SHA-256, 1 итерация | ключ B |
openssl enc -pbkdf2 | PBKDF2-HMAC-SHA256, 10 000 итераций | ключ C |
| Наш режим passphrase | PBKDF2-HMAC-SHA256, 600 000 итераций | ключ D |
| Java, Python, Go | никакого умолчания вообще; вывод пишете вы | что написали, то и получите |
Четыре ключа из одного пароля — и это до того, как кто-либо успел ошибиться. Обновление с 1.0.2 на 1.1 сменило дайджест по умолчанию с MD5 на SHA-256, и поэтому шифротекст из старых скриптов перестал расшифровываться той же командой на более новой машине. Если вам достались данные, а про инструменты никто уже не помнит, перебирайте схемы вывода в этом порядке. Это проверка из трех вариантов, а не поиск.
8. Что транспорт сделал с вашими байтами
Шифротекст — это равномерно случайные двоичные данные, то есть максимально враждебная нагрузка для всего, что обращается с байтами как с текстом. Большая доля сбоев AES вообще не задевает шифр.
Варианты Base64 и пропавшее дополнение
Стандартный base64 (RFC 4648 §4) использует + и /; URL-безопасный вариант (§5) использует - и _. URL-безопасная строка, отданная стандартному декодеру, либо бросает исключение, либо — в снисходительных декодерах — молча выбрасывает неподходящие символы и возвращает укороченные, съехавшие байты. Именно поэтому в Java Base64.getUrlDecoder() и Base64.getDecoder() — два разных объекта. Некоторые кодировщики к тому же отбрасывают завершающий =, некоторые декодеры на нем настаивают, а код вокруг JWT срезает его по умолчанию.
Прежде чем подозревать ключ, декодируйте шифротекст и сверьте его длину с режимом:
- CBC и ECB: ненулевое кратное 16. Что угодно другое — это обрезка или проблема декодирования, а не проблема ключа.
- GCM: длина шифротекста равна длине открытого текста, плюс 16 на тег, плюс 12 спереди, если nonce приписан к началу.
- CTR: любая длина, так что эта проверка не говорит ничего.
Декодер Base64 выдает число байт с одной вставки — часто это самый быстрый замер во всем расследовании.
Переводы строк, «умные» кавычки и путешествие через UTF-8
openssl base64 переносит вывод на новую строку каждые 64 колонки, если не передать -A, и одни декодеры встроенные переводы строк пропускают, а другие их отвергают, — так один и тот же файл декодируется на одной машине и падает на другой. Копирование через мессенджер или текстовый редактор превращает прямые кавычки в типографские, а дефисы — в короткие тире, и разница в терминале почти не видна.
Невосстановимый случай — путешествие через UTF-8. Если сырой вывод AES хоть раз побывал строкой без предварительного кодирования (new String(cipherBytes) в Java, bytes.decode('utf-8', errors='replace') в Python, TextDecoder где угодно), каждая последовательность байт, не являющаяся корректным UTF-8, схлопывается в U+FFFD, а обратное кодирование дает EF BF BD там, где были ваши данные. Поскольку примерно половина случайных байт выходит за пределы ASCII, большая часть шифротекста уничтожается, и никакой ключ ее не вернет; гид по кодировкам UTF-8 и UTF-16 объясняет, почему потеря необратима. Двоичный шифротекст путешествует в base64, в hex или как двоичные данные, но строкой он не путешествует никогда.
Колонки базы данных
Хранилище наносит тот же ущерб, только тише. Шифротекст, записанный в VARCHAR(255), который на один блок длиннее, обрезается, и MySQL вне строгого режима делает это без ошибки. В хвосте живут блок дополнения и тег GCM, так что строка, записанная «успешно» месяцы назад, теперь падает, а если срез пришелся ровно на границу 16 байт, то и проверка длины выше его не поймает. Остальное доделывает преобразование кодировок: колонка latin1, принимающая байты UTF-8, переписывает ваши данные прямо на входе.
Храните шифротекст в VARBINARY, BLOB или bytea — либо храните base64 в текстовой колонке с запасом.
9. Схема бисекции, которая находит причину за пять минут
Каждый раздел выше сужает одну переменную. Пройдите их по порядку, сверяясь с эталонной реализацией, которой управляете вы: поиск сходится быстро. Браузерные инструменты хорошо работают в роли такого эталона: всё выполняется прямо в браузере, ключ и шифротекст не покидают страницу, а настройки можно менять по одной и сразу видеть байты.
-
Шаг 0: измерьте форму. Декодируйте шифротекст и запишите число байт, первые несколько байт и то, начинается ли он с
U2FsdGVkX1. Сверьте число с разделом 8. Если оно не кратно 16, а вы уверены, что работаете в CBC, — остановитесь: это баг транспорта. -
Шаг 1: зашифруйте известный открытый текст. В инструменте шифрования AES зашифруйте короткую известную строку с теми параметрами, которые вы считаете боевыми, а потом сравнивайте не значения двух выводов, а их форму: общую длину, префиксные байты, наличие заголовка с солью. Несовпадение означает, что неверно ваше предположение о формате или о KDF, и никакая возня с ключом этого не исправит.
-
Шаг 2: переберите схемы вывода ключа. Для данных, выведенных из passphrase, прогоните в инструменте расшифровки AES PBKDF2 с точным числом итераций, затем EVP-SHA256, затем EVP-MD5. Верным может быть ровно один вариант. Если не работает ни один — баг лежит выше KDF.
-
Шаг 3: снимите все соглашения. Переключитесь на сырой ключ, включите голый шифротекст, задайте IV явно. Теперь вы прямо указываете, какие байты — ключ, какие — IV, а какие — шифротекст, и ничто не выводится по догадке. Если здесь расшифровывается, а в вашем коде нет, то ваш баг — в обвязке (неснятый префикс с IV, тег не на своем месте), а не в криптографии.
-
Шаг 4: переключите режим. Попробуйте на тех же байтах CBC, потом CTR, потом GCM. Если CTR возвращает читаемый текст там, где CBC падал, — это несовпадение режимов, и точка.
-
Шаг 5: прочитайте мусор. Первый блок плохой, остальное чистое — виноват IV. Первый блок чистый, остальное плохое — вы расшифровали CBC как ECB с нулевым IV. Плохо всё — виноват ключ или схема его вывода.
10. Частые вопросы
Почему код с AES работает локально, но падает в production?
Разница между локальной машиной и production лежит в чем-то, чего нет в системе контроля версий. Обычные подозреваемые по порядку: ключ пришел из переменной окружения или из менеджера секретов с лишним переводом строки; кодировка платформы по умолчанию в Java различается на ноутбуке и в контейнере, так что getBytes() дал разные байты (раздел 3); в production стоит OpenSSL 1.1+, а локальные скрипты писались под 1.0.2, и дайджест EVP_BytesToKey сменился с MD5 на SHA-256; либо колонка базы обрезает шифротекст только в одной среде. Сначала выведите на обеих сторонах длину ключа и длину шифротекста в байтах — эти два числа обычно и закрывают вопрос.
Зашифровал в Node.js, не могу расшифровать в Java. С чего начать?
Между Node.js и Java начинайте с тега GCM: самая частая и наименее очевидная причина. Node возвращает шифротекст и тег по отдельности; doFinal() в Java ждет их склеенными как ciphertext || tag, а раздел 6 показывает, что в остальном байты одинаковы. Если у вас CBC, начните с соглашения об IV: приписал ли его Node спереди и отрезает ли сторона Java 16 байт перед расшифровкой. Третий кандидат — сам ключ: Buffer.from(k, 'hex') и k.getBytes(StandardCharsets.UTF_8) дают из одной строки разную длину.
PKCS5Padding в Java — это то же самое, что PKCS#7?
Для AES PKCS5Padding и PKCS#7 — по существу одно и то же. PKCS#5 (RFC 8018) определен только для 8-байтовых блоков; PKCS#7 (RFC 5652) обобщает схему на размеры блока от 1 до 255 байт. PKCS5Padding в Java, примененный к блочному шифру с 16-байтовым блоком, реализует поведение PKCS#7, а само название — исторический хвост, так что вашим багом это не бывает никогда. А вот NoPadding бывает: он требует, чтобы открытый текст уже был кратен 16, и при расшифровке отдает дополнение обратно как данные — вы видите правдоподобный текст с хвостовыми байтами вида \x05\x05\x05\x05\x05.
Ключ из 32 символов, но AES говорит, что длина неверная. Почему?
Ошибка длины означает, что библиотека получила число байт, не равное 16, 24 или 32. Для строки из 32 символов это обычно лишний перевод строки (33 байта), префикс 0x, делающий строку некорректным hex, или не-ASCII символ, занимающий в UTF-8 два-три байта. Опаснее вариант, когда ошибки нет вовсе: 32 символа hex декодируются в 16 корректных байт, а 32 символа base64 — в 24 корректных байта, и обе длины для AES законны. Библиотека их принимает, использует неверный ключ и выдает вам сбой дополнения. Проверяйте число байт, а не число символов.
Расшифровка «удалась», но на выходе мусор. Что пошло не так?
Мусор на выходе при успешной расшифровке означает, что вы в режиме, который ничего не проверяет. CTR и ECB не бросают исключений никогда, а CBC бросает только тогда, когда финальный байтовый шаблон не проходит проверку дополнения, — с неверным ключом это происходит чуть менее чем в 0,4% случаев. Читайте форму: первые 16 байт испорчены, остальное чисто — виноват IV; первые 16 чисты, остальное испорчено — вы расшифровали шифротекст CBC как ECB с нулевым IV; испорчено равномерно — виноват ключ или схема его вывода. Читаемый текст с несколькими странными байтами в хвосте означает NoPadding на дополненных данных. Долгосрочное лечение — GCM, чтобы слово «удалось» что-то значило.
Можно ли расшифровать, если IV потерян?
Без IV в CBC расшифровывается всё, кроме первых 16 байт. Блоки со второго и дальше восстанавливаются как D(C_i) XOR C_{i-1}, и каждый вход этой формулы уже лежит в шифротексте, так что IV нужен только первому блоку. А если вы к тому же знаете, как начинается открытый текст — скажем, это JSON, начинающийся с {"userId":, — IV восстанавливается напрямую как D(C1) XOR P1. В CTR вектор инициализации задает весь ключевой поток, так что его потеря — это потеря всего. В GCM nonce питает и счетчик, и тег, так что частичного восстановления не бывает.
Можно ли восстановить текст, если тег GCM обрезан или потерян?
Восстановить текст без тега GCM математически можно, практически — с усилиями. Внутри GCM это режим CTR, так что ключа и nonce достаточно, чтобы воспроизвести ключевой поток. Ни одна массовая библиотека этого за вас не сделает: Java, Go, Python и Web Crypto все как одна отказываются отдавать открытый текст без корректного тега, и сделано это намеренно. Обходной путь — расшифровать те же байты как AES-CTR, задав начальный блок счетчика как 12-байтовый nonce, за которым идет 00000002: именно там начинается первый блок данных в GCM. Данные вы вернете, а все гарантии целостности отдадите, так что относитесь к результату как к недоверенному. Если все 16 байт тега у вас на месте, а аутентификация всё равно падает, то тег не потерян и ваш баг — что-то другое на этой странице. Несите его в инструмент расшифровки AES и начинайте с шага 0.