Генерация материала ключей шифрования PDF функцией Random из runtime-библиотеки — ловушка на Free Pascal: пока ничто не зовёт Randomize, каждый процесс выдаёт одну и ту же последовательность байтов, поэтому сборки Linux и macOS писали бы от запуска к запуску одинаковые ключи шифрования файлов, соли, CBC IV и префиксы nonce AES-GCM. PDFiumPas вместо этого читает /dev/urandom на не-Windows целях и бросает исключение, когда не может
Сама ловушка умещается в цикл из четырёх строк. Более полезный урок — почему набор тестов шифрования, AESV3 и AESV4, с PDF MAC и без, может оставаться зелёным прямо над ней. Случайность, константная в пределах процесса, невидима для любого теста, который выполняется внутри одного процесса, — а именно так обычно и организованы тесты шифрования
Где PDFiumPas нужны случайные байты?
Каждый случайный байт в стеке шифрования PDFiumPas приходит из одной-единственной процедуры, AesGenerateRandomBytes в юните FPdfAes, так что один плохой источник загрязняет всё сразу. Стандартный security handler из ISO 32000-2 §7.6.4 и расширение AESV4 из ISO/TS 32003 расходуют эти байты в следующих местах:
- 32-байтовый ключ шифрования файла, заново генерируемый DeriveEncryptionKeys для каждого документа и затем заворачиваемый в /UE и /OE под ключами, выведенными из пароля
- Две 16-байтовые соли, одна в последних 16 байтах /U, другая в последних 16 байтах /O, каждая разбита на 8-байтовую validation-соль и 8-байтовую key-соль
- Байты 12–15 открытого текста за /Perms, которые ISO 32000-2 заполняет случайными данными, прежде чем блок шифруется ключом файла
- 16-байтовый CBC IV, приставляемый перед каждой зашифрованной строкой и потоком в документе AESV3
- 8-байтовый префикс nonce для документов AESV4, за которым идёт 4-байтовый счётчик на объект, стартующий с нуля
- 32-байтовый /KDFSalt и ключ MAC, когда выставлен EnableIntegrityProtection
Почему каждый процесс получает один и тот же ключ?
Pseudo-random генератор RTL стартует с RandSeed = 0, если программа не зовёт Randomize, поэтому не-Windows ветка, заполняющая из него буфер ключей, получает одну и ту же последовательность в каждом процессе. Комментарий о том, что генератор засеивается из GetTickCount64, не помогает, когда ни одна строка кода этого не делает; комментарий оказывается единственным местом, где существует seed:
// Ловушка: незасеянный RTL-генератор как источник ключей
// (комментарий, обещающий seed из GetTickCount64, его не применяет)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
Последовательность перезапускается в каждом процессе и движется внутри него, так что первый документ, который шифрует любой процесс, делит ключ файла с первым документом каждого другого процесса той же сборки, второй со вторым и так далее. Ключ файла в R5, R6 и R7 вообще не зависит от пароля, потому что пароль его лишь заворачивает, — значит, любой, кто способен воспроизвести последовательность, полностью держит ключ в руках, не зная пароля. AESV4 добавляет второй отказ: тот же ключ с тем же 8-байтовым префиксом и счётчиком, стартующим с нуля, повторяет GCM nonce, что NIST SP 800-38D §8 запрещает напрямую. Повторённый GCM nonce под одним ключом раскрывает XOR двух открытых текстов и выдаёт подключ аутентификации, так что теги, на которые опираются шифрование AESV4-GCM и токен PDF MAC, перестают что-либо значить. Конфиденциальность и целостность уходят разом
Масштаб уже, чем мог бы внушить предыдущий абзац. На Windows вопрос не возникает: Windows-ветка зовёт CryptGenRandom через advapi32 с CRYPT_VERIFYCONTEXT и бросает исключение при неудаче. Ловушка специфична для не-Windows целей, что на практике значит приложения Lazarus и Free Pascal на Linux и macOS, — ещё один пункт в списке ловушек Delphi против FPC в сборках PDFium
Почему Randomize — не ответ?
Вызов Randomize прячет симптом, не чиня источник, потому что RandSeed — 32-битное значение, а Randomize выводит его из часов. Это ставит потолок возможных ключевых потоков на 2^32, и знание примерного времени создания файла срезает поиск куда ниже этой отметки, что рядом с 256-битным ключом AES — ничто. Материал ключей обязан приходить из пула энтропии ядра, поэтому AesGenerateRandomBytes читает /dev/urandom, ходит циклом по коротким чтениям и бросает исключение, если пул не может выдать каждый запрошенный байт:
Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
Remaining := Count;
while Remaining > 0 do
begin
Got := FileRead(Handle, P^, Remaining);
if Got <= 0 then
Break; // отказ или неожиданный конец потока
Inc(P, Got);
Dec(Remaining, Got);
end;
if Remaining = 0 then
Exit;
finally
FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');
Отказ намеренный, и он совпадает с тем, что Windows-ветка делает при недоступном CryptGenRandom. Провалившееся шифрованное сохранение — инцидент, который вы замечаете в тот же день; успешное сохранение с предсказуемыми ключами — то, о котором вы узнаёте от кого-то другого. Дальше два практических следствия. Минимальный контейнер или chroot без населённого /dev проваливает шифрование вместо тихой деградации, так что смонтируйте его. И поскольку исключение вылетает из TPdf.SaveAsEncrypted после того, как целевой файл открыт с fmCreate, на диске остаётся пустой выходной файл, который удаляет ваш обработчик ошибок
Почему round-trip тесты этого не ловят?
Round-trip тест не видит константную случайность, потому что расшифровка восстанавливает тот ключ файла, какой выбрало шифрование. Тест шифрует документ, снова открывает его с паролем, разворачивает ключ из /UE и расшифровывает каждый объект; предсказуемый ключ разворачивается и расшифровывается ровно так же, как случайный, а теги GCM верифицируются, потому что вычислены тем же самым ключом. Даже тест, шифрующий дважды и утверждающий, что два вывода различаются, проходит, ведь второй вызов в том же процессе берёт следующие байты последовательности. Свойство, которое важно, — другой ключ в каждом процессе — наблюдаемо только сравнением вывода между процессами. Когда один и тот же код и производит значение, и потребляет его, тесты слепы к целым классам дефектов, и случайность — чистейший пример
Как протестировать случайность ключей между процессами?
Запустите маленький зонд дважды как отдельные процессы на целевой платформе и сравните вывод. Зонд ниже зовёт DeriveEncryptionKeys и печатает соль, лежащую в байтах 32–47 записи /U. Это значение пишется в открытую в каждый зашифрованный файл, так что печать в логах CI ничего не раскрывает, а приходит оно из того же генератора, что и ключ файла:
program SaltProbe;
{$mode delphi}
uses
SysUtils, FPdfEncrypt;
var
Opts: TPdfEncryptOptions;
Keys: TPdfEncryptionKeys;
I: Integer;
Hex: string;
begin
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'probe';
Opts.Revision := erR6;
DeriveEncryptionKeys(Opts, Keys);
Hex := '';
for I := 32 to 47 do // /U = 32-байтовый хэш + 16-байтовая соль
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // должно различаться при каждом запуске
end.
Встройте зонд в сборку для каждой не-Windows цели: запустить дважды, валить джобу при совпадении строк. То же сравнение работает и на файлах, уже гуляющих по рукам. Возьмите два зашифрованных PDF, написанных разными запусками одного приложения, прочитайте строки /U из их словарей Encrypt и сравните последние 16 байтов; одинаковые соли означают, что источник ключей предсказуем, и этим документам нужно шифрование заново из их открытого текста, чтобы каждый получил свежий ключ файла. Общая привычка — гонять не-Windows кодовые пути на самой платформе, а не доверять Windows-прогону; на том же рассуждении стоит бэкенд таймстампов libcurl для не-Windows сборок
PDFiumPas — Delphi и Lazarus PDF-компонент, построенный на движке PDFium, с AES-256, AES-GCM и токеном PDF MAC, реализованными нативно на Pascal, и материалом ключей из генератора операционной системы на каждой платформе. Подробности и загрузки — на странице компонента PDFium Delphi