Преди версия 3.114.8 PDFiumPas генерираше PDF encryption key материал на non-Windows цели с Random функцията на runtime библиотеката, а понеже никой не викаше Randomize, всеки процес произвеждаше една и съща байтова последователност. Free Pascal build-ове на Linux и macOS затова записваха еднакви file encryption ключове, соли, CBC IV-та и AES-GCM nonce префикси, прогон след прогон. Версия 3.114.8 вместо това чете /dev/urandom и вдига exception, когато не може
Самият дефект е цикъл от четири реда. По-полезният урок е защо тестов suite, който шифрова и дешифрова стотици документи — с AESV3 и AESV4, с и без PDF MAC — оставаше зелен цялото време. Случайност, която е константа за процеса, е невидима за всеки тест, който върти в рамките на един процес, а точно така обикновено се пишат encryption тестовете
Къде PDFiumPas се нуждае от случайни байтове?
Всеки случаен байт в encryption стекът на PDFiumPas идва от една-единствена процедура, AesGenerateRandomBytes в unit-а FPdfAes, така че един лош източник замърсява всичко. Стандартният security handler в ISO 32000-2 §7.6.4 и AESV4 разширението в ISO/TS 32003 консумират тези байтове на тези места:
- 32-байтовият file encryption ключ, генериран наново от DeriveEncryptionKeys за всеки документ и после опакован в /UE и /OE под ключове, извлечени от паролата
- Две 16-байтови соли, една в последните 16 байта на /U и една в последните 16 байта на /O, всяка разделена на 8-байтова validation сол и 8-байтова key сол
- Байтове 12 до 15 от plaintext-а зад /Perms, които ISO 32000-2 запълва със случайни данни, преди блокът да е шифрован под file ключа
- 16-байтов CBC IV, добавян пред всяко шифровано string и stream в AESV3 документ
- 8-байтов nonce префикс за AESV4 документи, следван от 4-байтов брояч на обект, започващ от нула
- 32-байтовият /KDFSalt и MAC ключът, когато EnableIntegrityProtection е вдигнат
Защо всеки процес произвеждаше един и същ ключ?
AesGenerateRandomBytes ползваше генератора на операционната система само на Windows; навсякъде другаде запълваше буфера от RTL pseudo-random генератора, а този генератор тръгва от RandSeed = 0, освен ако програмата не вика Randomize. Коментарът над цикъла твърдеше, че генераторът се сее от GetTickCount64. Нито един ред код не го правеше, което направи коментара единственото място, където seed-ът съществуваше:
// Non-Windows разклонение на AesGenerateRandomBytes преди 3.114.8
// (коментарът над него обещаваше GetTickCount64 seed, който никога не е прилаган)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
Последователността рестартира с всеки процес и напредва в него, така че първият документ, който даден процес шифрова, споделя file ключа си с първия документ на всеки друг процес, въртящ същия build, вторият с втория и така нататък. File ключът в R5, R6 и R7 изобщо не зависи от паролата, защото паролата само го опакова, което значи, че всеки, способен да възпроизведе последователността, държи ключа, без да знае парола. AESV4 добавя втори провал: същият ключ със същия 8-байтов префикс и брояч, рестартиращ от нула, повтаря GCM nonces, което NIST SP 800-38D §8 забранява изрично. Повторен GCM nonce под един ключ разкрива XOR-а на двата plaintext-а и излага authentication subkey-я, така че таговете, на които разчита AESV4-GCM шифроването и PDF MAC токенът, спират да значат каквото и да е. Конфиденциалност и интегритет си отиват заедно
Обхватът е по-тесен, отколкото абзацът може да подскаже. Windows build-ове никога не са били засегнати, защото Windows разклонението винаги е викало CryptGenRandom през advapi32 с CRYPT_VERIFYCONTEXT и е вдигало грешка при провал. Изложен беше изходът от non-Windows build-ове, по-стари от 3.114.8, което на практика значи Lazarus и Free Pascal приложения на Linux и macOS — още един запис в списъка с Delphi срещу FPC капани в PDFium build-ове
Защо Randomize никога не беше правилният fix?
Викът на Randomize би скрил симптома, без да поправи източника, защото RandSeed е 32-битова стойност и Randomize я извлича от часовника. Това ограничава броя възможни key потоци на 2^32, а приблизителното знание кога е записан файлът рязко сваля търсенето под това, което до 256-битов AES ключ не значи нищо. Key материалът трябва да идва от kernel entropy pool-а, затова AesGenerateRandomBytes в 3.114.8 чете /dev/urandom, обикаля кратки четения и вдига грешка, ако pool-ът не може да достави всеки искан байт:
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 липсва. Провалена шифрована запис е инцидент, който забелязвате същия ден; успешен запис с предвидими ключове е инцидент, за който научавате от друг. Две практически последствия следват. Минимален container или chroot без попълнен /dev вече се проваля при шифроване, вместо тихо да се влошава, значи mount-нете го. И понеже exception-ът излиза от TPdf.SaveAsEncrypted, след като целевият файл е отворен с fmCreate, празен изходен файл остава след себе си за вашия error handler да го изтрие
Защо round-trip тестовете никога не го хванаха?
Round-trip тест не вижда константна случайност, защото дешифроването възстановява какъвто и да е file ключ, който шифроването е избрало. Тестът шифрова документ, отваря го наново с паролата, разгръща ключа от /UE и дешифрова всеки обект; предвидим ключ се разгръща и дешифрова също толкова добре колкото случаен, а GCM таговете се валидират, защото са изчислени с онзи същия ключ. Дори тест, който шифрова два пъти и асертира, че двата изхода се различават, минава, понеже второто извикване в същия процес тегли следващите байтове на последователността. Свойството, което има значение — различен ключ във всеки процес — е наблюдаемо само чрез сравняване на изхода през процеси. Когато един и същ код едновременно произвежда и консумира стойност, тестовете са сляпи за цели класове дефекти, а случайността е най-чистият пример
Как да тествате key случайността през процеси?
Пуснете малка сонда два пъти като отделни процеси на целевата платформа и сравнете изхода. Сондата по-долу вика DeriveEncryptionKeys и принтира солта, съхранена в байтове 32 до 47 на записа /U. Тази стойност се записва на видимо място във всеки шифрован файл, така че принтирането ѝ в CI логове не разкрива нищо, а идва от същия генератор като file ключа:
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-байтов hash + 16-байтова сол
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // трябва да се различава на всеки прогон
end.
Вкарайте сондата в build-а за всяка non-Windows цел: пускайте я два пъти и проваляйте job-а, ако двата реда съвпаднат. Същото сравнение работи и върху вече circulation файлове. Вземете два шифровани PDF-а, записани от различни прогона на същото приложение, прочетете /U string-овете от Encrypt речниците им и сравнете последните 16 байта; еднакви соли идентифицират засегнат build, а документите трябва да се шифроват наново от своя plaintext с 3.114.8 или по-нова, така че всеки да получи свеж file ключ. Общият навик е non-Windows code пътищата да се упражняват на самата платформа, вместо да се вярва на Windows прогона — същото разсъждение зад libcurl timestamp backend-а за non-Windows build-ове
PDFiumPas е Delphi и Lazarus PDF компонент, изграден върху PDFium engine, с AES-256, AES-GCM и PDF MAC токен, имплементирани нативно в Pascal, и key материал, теглен от генератора на операционната система на всяка платформа. Детайли и изтегляния има на страницата на PDFium Delphi компонента