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

Повтор ключей шифрования PDF на FPC: фикс RNG в PDFiumPas

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

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

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

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

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

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

PDFiumPas — Delphi и Lazarus PDF-компонент, построенный на движке PDFium, с AES-256, AES-GCM и токеном PDF MAC, реализованными нативно на Pascal, и материалом ключей из генератора операционной системы на каждой платформе. Подробности и загрузки — на странице компонента PDFium Delphi