До версии 3.114.8 PDFiumPas генерировал материал ключей шифрования PDF на не-Windows целях функцией Random из runtime-библиотеки, и поскольку ничто не звало Randomize, каждый процесс выдавал одну и ту же последовательность байтов. Сборки Free Pascal на Linux и macOS, таким образом, писали от запуска к запуску одинаковые ключи шифрования файлов, соли, CBC IV и префиксы nonce AES-GCM. Версия 3.114.8 вместо этого читает /dev/urandom и бросает исключение, когда не может
Сам дефект — цикл из четырёх строк. Более полезный урок — почему набор тестов, шифровавший и расшифровывавший сотни документов, с 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
Почему каждый процесс выдавал один и тот же ключ?
AesGenerateRandomBytes использовала генератор операционной системы только на Windows; везде иначе она заполняла буфер из RTL-псевдослучайного генератора, а тот стартует с RandSeed = 0, если программа не зовёт Randomize. Комментарий над циклом утверждал, что генератор засеивается из GetTickCount64. Ни одна строка кода этого не делала, так что комментарий оказался единственным местом, где существовал этот seed:
// Не-Windows ветка AesGenerateRandomBytes до 3.114.8
// (комментарий над ней обещал 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 сборок раньше 3.114.8, что на практике значит приложения Lazarus и Free Pascal на Linux и macOS, — ещё один пункт в списке ловушек Delphi против FPC в сборках PDFium
Почему Randomize никогда не был верным фиксом?
Вызов Randomize спрятал бы симптом, не починив источник, потому что RandSeed — 32-битное значение, а Randomize выводит его из часов. Это ставит потолок возможных ключевых потоков на 2^32, и знание примерного времени создания файла срезает поиск куда ниже этой отметки, что рядом с 256-битным ключом AES — ничто. Материал ключей обязан приходить из пула энтропии ядра, поэтому AesGenerateRandomBytes в 3.114.8 читает /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 байтов; одинаковые соли опознают затронутую сборку, а документы стоит зашифровать заново из их открытого текста версией 3.114.8 или новее, чтобы каждый получил свежий ключ файла. Общая привычка — гонять не-Windows кодовые пути на самой платформе, а не доверять Windows-прогону; на том же рассуждении стоит бэкенд таймстампов libcurl для не-Windows сборок
PDFiumPas — Delphi и Lazarus PDF-компонент, построенный на движке PDFium, с AES-256, AES-GCM и токеном PDF MAC, реализованными нативно на Pascal, и материалом ключей из генератора операционной системы на каждой платформе. Подробности и загрузки — на странице компонента PDFium Delphi