Когда PDF AES-256 revision 6 отвергает пароль, в верности которого вы уверены, обычный виновник — счётчик раундов в укреплённом хэше R6 (Algorithm 2.B из ISO 32000-2). У PDFium Component for Delphi ровно этот баг был до v2.57.2: TPdf.SaveAsEncrypted считал раунды от неверной базы, и примерно 30% созданных им файлов отвергались FPDF_LoadDocument с FPDF_ERR_PASSWORD
Противная часть не в арифметике. Противная часть в том, что дефект был невидим любому тесту, который шифровал файл и затем проверял его тем же кодом, и что одна удачная круговая поездка не доказывала ничего. Этот пост проходит по тому, чего Algorithm 2.B требует на самом деле, где прячется сбой на один раунд, почему он падает лишь иногда и как построить регрессионный тест, ловящий такой баг раньше клиента
Что правило завершения хэша R6 говорит на самом деле?
Algorithm 2.B останавливается только после минимум 64 раундов и притом лишь когда последний байт последнего вывода AES E меньше или равен номеру раунда минус 32, где номер раунда — это счёт уже выполненных раундов от единицы. После N-го раунда вы сравниваете с N. Не с N − 1, не с индексом следующего раунда. Это одно определение — вся поверхность бага
Остальная часть алгоритма механическая. Стартуйте с K как SHA-256 от пароля, соли и (для ключа владельца) 48-байтового значения /U. Каждый раунд строит K1 как password ‖ K ‖ UK, повторённые 64 раза, шифрует его AES-128-CBC без набивки, беря первые 16 байтов K как ключ и следующие 16 как IV, берёт первые 16 байтов E как big-endian 128-битное целое mod 3 и сообразно выбирает SHA-256, SHA-384 или SHA-512 для следующего K. Финальный хэш — первые 32 байта K. Определяет его ISO 32000-2 §7.6.4.3; длина цикла, зависящая от данных, намеренна: она делает работу на каждую догадку пароля непредсказуемой для GPU-крэкера
Вот правильная форма цикла — иллюстрация, а не готовая к вставке реализация. BuildK1, AesCbcNoPad128, FirstBlockMod3 и хэш-хелперы стоят на месте любых примитивов, что уже есть в вашей кодовой базе
// Иллюстрация: скелет цикла Algorithm 2.B (ISO 32000-2, revision 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0; // выполнено раундов к этому моменту
repeat
Inc(RoundNumber); // это раунд 1, 2, 3 ...
K1 := BuildK1(Password, K, UK); // (Password || K || UK), повторённые 64 раза
E := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
case FirstBlockMod3(E) of // первые 16 байтов E, big-endian, mod 3
0: K := Sha256(E);
1: K := Sha384(E);
2: K := Sha512(E);
end;
until (RoundNumber >= 64) and (E[High(E)] <= RoundNumber - 32);
Result := Copy(K, 0, 32);
Читайте строку завершения с прицелом на счётчик. Инкремент сидит вверху тела, поэтому к моменту теста в until RoundNumber держит ровно число завершённых раундов — то, что стандарт называет номером раунда
Где сбой на единицу прячется в порте на Delphi
Баг PDFium Component — счётчик, стартовавший с −1 при инкременте вверху цикла, поэтому значение, проверяемое в ветке until, всегда было числом выполненных раундов минус один. Ломается сразу две вещи: минимум в 64 раунда становится 65, а байтовый порог на одну ступень жёстче, чем говорит стандарт. Оба сдвигают точку выхода ровно на один раунд в тех случаях, где это важно
У того же сбоя несколько одинаково правдоподобных маскировок. Начать с 0 и инкрементировать внизу тела, когда тест уже написан против доинкрементного значения. Использовать 0-базный индекс цикла for и повторно применить его в сравнении. Перевести C-референс, чья переменная цикла проверяется до ++. Каждая из них читается естественно на ревью, и каждая даёт хэш, выглядящий совершенно случайным, — потому что он и случаен: это просто другой, столь же правдоподобно выглядящий вывод SHA, который не вычисляет ни один другой читатель. Исправленный CalculateR6Hash в FPdfEncrypt.pas теперь несёт комментарий, фиксирующий семантику от единицы, — именно потому, что неверная версия не выглядит неверной
Почему PDF AES-256 R6 падает лишь иногда?
Отказ вероятностный, потому что лишний раунд меняет результат, только когда завершающий байт попадает в однораундное окно между верным порогом и сдвинутым. Во всех прочих случаях обе версии выходят на одном раунде и дают тот же K, поэтому файл открывается нормально. В измерении PDFium Component 18 из 60 шифрований (около 30%) дали значения /U, /O, /UE и /OE, расходящиеся со стандартной реализацией; остальные 70% были побайтово верны
Свежие случайные соли вытягиваются при каждом сохранении, поэтому тот же документ с тем же паролем проходит один прогон и падает на следующем. Из саппорта это выглядит как глючное хранилище, битая загрузка или пользователь, опечатавшийся в пароле. Никто не думает винить счётчик цикла, когда семь файлов из десяти открываются. Если ваш продукт ловит перемежающиеся отчёты «неверный пароль» на PDF, зашифрованных вашим же кодом, и только на revision 6, — вот первое место для поиска
Revision 5 невосприимчив: там один проход SHA-256 без итераций. Путь AESV4 из ISO/TS 32003 — нет: значение erR7 в TPdfEncryptionRevision переиспользует Algorithm 2.B для хэшей паролей, так что тот же фикс покрывает GCM-файлы из шифрования AES-256-GCM и целостности PDF MAC по ISO/TS 32003
Ловушка самосогласованности: почему round-trip тесты проходят
Круговая поездка через собственное шифрование и собственную проверку всегда успешна, потому что обе стороны гоняют один и тот же неверный цикл и сходятся на одном и том же неверном хэше. Дефект существует только относительно чужой реализации, поэтому судьёй обязан быть независимый: движок PDFium в pdfium.dll, Acrobat, qpdf или любой читатель, который писали не вы
Это некомфортный общий урок для всех, кто пишет околокриптографический документный код. Юнит-тесты, вычисляющие хэш и сравнивающие его с выводом тестируемой функции, доказывают детерминизм, а не корректность. Known-answer векторы из стандарта или референсного инструмента лучше, а круговая поездка между реализациями — самое близкое к истине, что у вас есть. PDFium Component здесь в выигрышном положении: шифрование выполняется на Pascal в EncryptDocumentBytes, а расшифровка при загрузке идёт через собственный C++ security handler PDFium, так что повторное открытие сохранённого файла через TPdf — настоящая независимая проверка, а не зеркало
uses
SysUtils, PDFium, FPdfEncrypt;
procedure SaveR6(const Source, Target: string);
var
Pdf: TPdf;
Opts: TPdfEncryptOptions;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := Source;
Pdf.Active := True;
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'userpw';
Opts.OwnerPassword := 'ownerpw';
Opts.Revision := erR6; // AESV3, хэш пароля по Algorithm 2.B
if not Pdf.SaveAsEncrypted(Target, Opts) then
raise Exception.Create('SaveAsEncrypted failed');
finally
Pdf.Free;
end;
end;
TPdfEncryptOptions.Default стартует на revision 5 со всеми разрешёнными разрешениями и шифрованными метаданными, поэтому задавайте Revision явно, когда нужен R6. TPdf.SaveAsEncryptedToStream принимает ту же запись опций, когда вывод идёт в TStream вместо файла. Заметьте: пароли в записи опций — AnsiString, что важно при передаче не-ASCII учётных данных; нормализуйте и кодируйте их до того, как они попадут в запись
Как регрессионно тестировать вероятностный баг шифрования?
Повторяйте круговую поездку между реализациями столько раз, чтобы частота отказа 30% не могла проскочить. Один прогон пропускает баг PDFium Component примерно в 70% случаев. Двадцать прогонов, каждый со свежей солью, опускают частоту пропуска до примерно 0,08% (0.7 в двадцатой степени) — потому поставляемый регрессионный тест крутит цикл 20 раз, а не один
procedure TEncryptTests.R6_EncryptedDocumentReopensWithUserPassword;
var
Pdf: TPdf;
I: Integer;
Target: string;
begin
Target := TempOutputPath('encrypt_r6_roundtrip.pdf');
for I := 1 to 20 do
begin
SaveR6(SamplePdf, Target); // свежие соли при каждом сохранении
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'userpw';
Pdf.FileName := Target;
Pdf.Active := True; // PDFium проверяет /U независимо
AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
finally
Pdf.Free;
end;
end;
end;
Добавьте к нему негативный случай — неверный пароль, обязанный падать, — чтобы сломанный загрузчик не мог сделать позитивный тест вакуумно проходящим. И если собираете Lazarus-ом, пересоберите пакет библиотеки перед запуском тестового проекта после правки Lib\*.pas: тестовая сборка линкует прекомпилированные юниты .ppu пакета, и прогон против устаревшего объектного кода даёт обнадёживающий зелёный на баге, который вы на самом деле не починили. Засев — смежная ловушка, которую стоит пересмотреть тем же проходом: FPC Random, выдающий постоянные ключи шифрования — ещё один случай, где вывод выглядит случайным и тихо неверен
FAQ
Затрагивает ли баг счётчика раундов R6 файлы, зашифрованные на revision 5?
Нет. Revision 5 хэширует пароль одним проходом SHA-256 и не имеет счётчика итераций, поэтому счётчика раундов, который можно испортить, просто нет. Затронуты только revision 6 и ревизия AESV4 из ISO/TS 32003, использующие Algorithm 2.B
Можно ли всё же открыть файл, записанный багованной версией?
Около 70% из них открываются нормально, потому что их хэш случайно совпал. Остальные несут значения /U и /O, которые не воспроизводит ни один стандартный читатель, поэтому проверка пароля проваливается везде; практический фикс — перешифровать из источника в открытом тексте версией v2.57.2 или новее
Почему тестировать против PDFium, а не собственного кода расшифровки?
Ваш собственный дешифратор делит реализацию Algorithm 2.B с вашим шифровщиком, поэтому неверное число раундов сокращается, и каждая круговая поездка проходит. Независимая реализация вроде security handler PDFium — единственная проверка, способная с вами не согласиться
Шифрование, валидация PDF MAC и остальной конвейер сохранения поставляются в PDFium Component for Delphi и C++Builder — с исходниками и регрессионными тестами для Delphi и Lazarus