Техническая статья

Почему ключам шифрования PDF нужен /dev/urandom на FPC

Генерация материала ключей шифрования 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
Каждый случайный байт в стеке шифрования PDFiumPas течёт из AesGenerateRandomBytes в FPdfAes в шесть потребителей: 32-байтовый ключ шифрования файла, заворачиваемый в /UE и /OE, соли /U и /O, байты-набивка /Perms, CBC IV для AESV3, префикс nonce GCM для AESV4, а также соль KDF и ключ MAC
Один общий генератор означает, что один плохой источник загрязнил бы ключевой материал везде разом, а один хороший источник покрывает все места вызова

Почему каждый процесс получает один и тот же ключ?

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, перестают что-либо значить. Конфиденциальность и целостность уходят разом

Незасеянный RTL-генератор в PDFiumPas на не-Windows сборках FPC: при RandSeed 0 каждый процесс выдаёт одну и ту же последовательность, поэтому документ один в процессе A несёт тот же ключ файла, что документ один в процессе B, а AESV4 повторяет GCM nonce, потому что тот же ключ встречает тот же префикс при счётчике, стартующем с нуля
Поскольку ключ файла никогда не зависит от пароля, любой, кто способен воспроизвести последовательность, полностью держит ключ в руках, а повторённые GCM nonce уничтожают конфиденциальность и целостность вместе

Масштаб уже, чем мог бы внушить предыдущий абзац. На 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 ничего не раскрывает, а приходит оно из того же генератора, что и ключ файла:

Межпроцессный тест случайности для PDFiumPas: программа SaltProbe зовёт DeriveEncryptionKeys и печатает hex байтов /U с 32 по 47, джоба гоняет её дважды как отдельные процессы и падает при совпадении строк, а выпущенные PDF сравниваются по последним 16 байтам их строк /U
Константная случайность невидима внутри одного процесса, потому что расшифровка восстанавливает тот ключ, какой выбрало шифрование, поэтому важное свойство наблюдаемо только сравнением вывода между процессами
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