До версії 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, тож одне погане джерело заражає все. Стандартний обробник безпеки в 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. Жодного рядка коду, який би це робив, не існувало, тож коментар був єдиним місцем, де існував засів:
// Не-Windows гілка AesGenerateRandomBytes до 3.114.8
// (коментар над нею обіцяв засів 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 теги звіряються, бо обчислені тим самим ключем. Навіть тест, який шифрує двічі і стверджує, що два виходи різні, проходить, бо другий виклик у тому самому процесі бере наступні байти послідовності. Властивість, яка має значення — інший ключ у кожному процесі, — спостережувана лише порівнянням виходів крізь процеси. Щоразу, коли той самий код і породжує, і споживає значення, тести сліпі до цілих класів дефектів, а випадковість — найчистіший приклад
Як протестувати випадковість ключів крізь процеси?
Запустіть маленький probe двічі як окремі процеси на цільовій платформі і порівняйте вихід. Probe нижче викликає 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.
Вмонтуйте probe у збірку для кожної не-Windows цілі: прогін двічі, job падає, якщо два рядки збіглися. Той самий прийом працює на файлах, що вже в обігу. Візьміть два зашифровані PDF, написані різними прогонами того самого застосунку, прочитайте рядки /U з їхніх словників Encrypt і порівняйте останні 16 байтів; однакові солі ідентифікують зачеплену збірку, і документи слід зашифрувати знову з відкритого тексту на 3.114.8 чи новішій, щоб кожен отримав свіжий ключ файлу. Загальна звичка — вправляти не-Windows шляхи коду на самій платформі, а не довіряти прогону Windows; та сама логіка стоїть за бекендом часових штампів libcurl для не-Windows збірок
PDFiumPas — це PDF-компонент для Delphi і Lazarus, збудований на рушії PDFium, з AES-256, AES-GCM і PDF MAC токеном, реалізованими нативно в Pascal, і матеріалом ключів з системного генератора на кожній платформі. Деталі й завантаження — на сторінці компонента PDFium Delphi