Před verzí 3.114.8 generovalo PDFiumPas klíčový materiál pro PDF šifrování na non-Windows targetech funkcí Random z runtime knihovny a protože nic nevolalo Randomize, každý proces produkoval tutéž bajtovou sekvenci. Free Pascal buildy na Linuxu a macOS proto zapisovaly identické file encryption klíče, soli, CBC IV a AES-GCM nonce prefixy běh za během. Verze 3.114.8 místo toho čte /dev/urandom a když to nejde, vyhodí výjimku
Samotná vada je čtyřřádková smyčka. Užitečnější poučení je, proč testovací sada, která šifruje a dešifruje stovky dokumentů, s AESV3 a AESV4, s PDF MAC i bez něj, byla celou dobu zelená. Náhodnost konstantní per proces je neviditelná pro každý test, který běží uvnitř jednoho procesu, a přesně tak se šifrovací testy obvykle píšou
Kde PDFiumPas potřebuje náhodné byty?
Každý náhodný byte v šifrovacím stacku PDFiumPas pochází z jediné procedury, AesGenerateRandomBytes v unitu FPdfAes, takže jeden špatný zdroj kontaminuje všechno najednou. Standardní security handler v ISO 32000-2 §7.6.4 a rozšíření AESV4 v ISO/TS 32003 tyhle byty konzumují na těchhle místech:
- 32bytový file encryption klíč, generovaný čerstvě DeriveEncryptionKeys pro každý dokument a pak zabalený do /UE a /OE pod klíči odvozenými z hesla
- Dvě 16bytové soli, jedna uložená v posledních 16 bytech /U a druhá v posledních 16 bytech /O, každá rozdělená na 8bytovou validation sůl a 8bytovou key sůl
- Byty 12 až 15 plaintextu za /Perms, které ISO 32000-2 plní náhodnými daty, než se blok zašifruje pod file klíčem
- 16bytový CBC IV připojovaný před každý šifrovaný string a stream v dokumentu AESV3
- 8bytový nonce prefix pro dokumenty AESV4, za ním 4bytový per-object counter začínající na nule
- 32bytová /KDFSalt a MAC klíč, když je nastavené EnableIntegrityProtection
Proč produkoval každý proces tentýž klíč?
AesGenerateRandomBytes používala generátor operačního systému jen na Windows; všude jinde plnila buffer z pseudo-random generátoru RTL a ten startuje z RandSeed = 0, pokud program nevolá Randomize. Komentář nad smyčkou tvrdil, že generátor se sází z GetTickCount64. Žádný řádek kódu to nikdy nedělal, takže komentář byl jediné místo, kde ten seed existoval:
// Non-Windows větev AesGenerateRandomBytes před 3.114.8
// (komentář nad ní slíbil seed z GetTickCount64, který se nikdy neaplikoval)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
Sekvence se restartuje s každým procesem a uvnitř něj postupuje, takže první dokument, který kterýkoli proces zašifruje, sdílí svůj file klíč s prvním dokumentem každého dalšího procesu běžícího stejný build, druhý s druhým a tak dál. File klíč v R5, R6 a R7 nezávisí na hesle vůbec, protože heslo ho jen zabaluje, takže kdokoli, kdo umí sekvenci reprodukovat, drží klíč, aniž by znal heslo. AESV4 přidává druhé selhání: tentýž klíč se stejným 8bytovým prefixem a counterem restartujícím se na nule opakuje GCM nonce, což NIST SP 800-38D §8 zakazuje naprosto. Opakovaný GCM nonce pod jedním klíčem odhalí XOR obou plaintextů a prozradí authentication subkey, takže tagy, na které se spoléhá šifrování AESV4-GCM a PDF MAC token, přestávají znamenat cokoli. Důvěrnost a integrita odcházejí společně
Rozsah je užší, než tenhle odstavec může naznačovat. Windows buildy nebyly nikdy zasažené, protože Windows větev vždy volala CryptGenRandom přes advapi32 s CRYPT_VERIFYCONTEXT a vyhodila výjimku, když to selhalo. Exponovaný byl výstup z non-Windows buildů starších než 3.114.8, což v praxi znamená Lazarus a Free Pascal aplikace na Linuxu a macOS — další položka do seznamu pastí Delphi versus FPC v PDFium buildech
Proč nikdy nebyl Randomize správná oprava?
Zavolat Randomize by schovalo symptom bez opravy zdroje, protože RandSeed je 32bitová hodnota a Randomize ji odvozuje z hodin. To dává strop počtu možných klíčových streamů na 2^32 a hrubá znalost doby, kdy soubor vznikl, srazí hledání hluboko pod to, což oproti 256bitovému AES klíči není nic. Klíčový materiál musí pocházet z kernel entropy poolu, takže AesGenerateRandomBytes ve 3.114.8 čte /dev/urandom, řeší krátké čtení ve smyčce a vyhodí výjimku, když pool nedodá každý požadovaný byte:
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; // selhání nebo nečekaný konec streamu
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');
Odmítnutí je záměrné a sedí k tomu, co Windows větev dělala vždycky, když CryptGenRandom není k mání. Selhané šifrované uložení je incident, který si všimnete ten stejný den; úspěšné uložení s předvídatelnými klíči je takové, o kterém se dozvíte od někoho jiného. Plynou z toho dva praktické důsledky. Minimální kontejner nebo chroot bez naplněného /dev teď u šifrování selže místo potichu degradovat, takže ho přimontujte. A protože výjimka propaguje ven z TPdf.SaveAsEncrypted poté, co byl cílový soubor otevřený s fmCreate, zůstane po ní prázdný výstupní soubor, který si smaže váš error handler
Proč to round-trip testy nikdy nechytily?
Round-trip test nevidí konstantní náhodnost, protože dešifrování vytáhne jakýkoli file klíč, který si šifrování vybralo. Test zašifruje dokument, znovu ho otevře s heslem, vytáhne klíč z /UE a dešifruje každý objekt; předvídatelný klíč se rozbalí a dešifruje úplně stejně dobře jako náhodný a GCM tagy projdou verifikací, protože byly spočítané s tím samým klíčem. I test, který šifruje dvakrát a assertuje, že se dva výstupy liší, projde, protože druhé volání ve stejném procesu čerpá další byty sekvence. Vlastnost, na které záleží — jiný klíč v každém procesu — je pozorovatelná jen porovnáním výstupu napříč procesy. Kdykoli tentýž kód hodnotu jak produkuje, tak konzumuje, jsou testy slepé k celým třídám vad a náhodnost je ta nejčistší ukázka
Jak otestujete náhodnost klíčů napříč procesy?
Spusťte malou sondu dvakrát jako oddělené procesy na cílové platformě a porovnejte výstup. Sonda níže volá DeriveEncryptionKeys a vypíše sůl uloženou v bytech 32 až 47 položky /U. Ta hodnota se píše na očích do každého šifrovaného souboru, takže její výpis v CI logu nic neprozradí, a přece pochází ze stejného generátoru jako file klíč:
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 = 32bytový hash + 16bytvá sůl
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // musí se lišit při každém běhu
end.
Zapojte sondu do buildu pro každý non-Windows target: pusťte ji dvakrát a failněte job, když se dva řádky rovnají. Totéž porovnání funguje na souborech už v oběhu. Vezměte dva šifrované PDF napsané různými běhy téže aplikace, přečtěte stringy /U z jejich slovníků Encrypt a porovnejte posledních 16 bytů; identické soli identifikují zasažený build a dokumenty by se měly znovu zašifrovat z jejich plaintextu s 3.114.8 nebo novější, aby každý dostal čerstvý file klíč. Obecný zvyk je cvičit non-Windows code pathy na platformě samotné místo důvěřování Windows běhu — totéž uvažování stojí za timestamp backendem libcurl pro non-Windows buildy
PDFiumPas je PDF komponenta pro Delphi a Lazarus postavená na engine PDFium, s AES-256, AES-GCM a PDF MAC tokenem implementovanými nativně v Pascalu a klíčovým materiálem čerpaným z generátoru operačního systému na každé platformě. Detaily a stahování jsou na stránce komponenty PDFium pro Delphi