Pred verziou 3.114.8 generovalo PDFiumPas materiál PDF šifrovacích kľúčov na cieľoch mimo Windows funkciou Random runtime knižnice a keďže nič nezavolalo Randomize, každý proces produkoval tú istú bajtovú postupnosť. Free Pascal buildy na Linuxe a macOS preto zapisovali identické file šifrovacie kľúče, soli, CBC IV a AES-GCM nonce prefixy beh za behom. Verzia 3.114.8 namiesto toho číta /dev/urandom a vyhodí výnimku, keď to nedokáže
Samotná defektná časť je slučka na štyri riadky. Užitočnejšou lekciou je, prečo test suite, ktorý šifruje a dešifruje stovky dokumentov, s AESV3 a AESV4, s PDF MAC aj bez neho, zostal celý čas zelený. Náhoda, ktorá je na proces konštantná, je neviditeľná pre každý test bežiaci vnútri jedného procesu a presne tak sa šifrovacie testy obyčajne píšu
Kde potrebuje PDFiumPas náhodné bajty?
Každý náhodný bajt v encryption stacku PDFiumPas pochádza z jedinej procedúry, AesGenerateRandomBytes v unit FPdfAes, takže jeden zlý zdroj kontaminuje všetko. Štandardný security handler v ISO 32000-2 §7.6.4 a rozšírenie AESV4 v ISO/TS 32003 konzumujú tie bajty na týchto miestach:
- 32-bajtový file encryption key, generovaný čerstvý DeriveEncryptionKeys pre každý dokument a potom zabalený do /UE a /OE pod kľúčmi odvodenými z hesla
- Dve 16-bajtové soli, jedna uložená v posledných 16 bajtoch /U a jedna v posledných 16 bajtoch /O, každá rozdelená na 8-bajtovú validation soľ a 8-bajtovú key soľ
- Bajty 12 až 15 plaintextu za /Perms, ktoré ISO 32000-2 plní náhodnými dátami skôr, než sa blok zašifruje pod file kľúčom
- 16-bajtové CBC IV pripojené pred každý zašifrovaný string a stream v dokumente AESV3
- 8-bajtový nonce prefix pre dokumenty AESV4, za ním 4-bajtové per-object počítadlo začínajúce na nule
- 32-bajtová /KDFSalt a MAC kľúč, keď je nastavené EnableIntegrityProtection
Prečo produkoval každý proces ten istý kľúč?
AesGenerateRandomBytes používala generátor operačného systému len na Windows; všade inde plnila buffer z RTL pseudo-náhodného generátora a ten začína od RandSeed = 0, pokiaľ program nezavolá Randomize. Komentár nad slučkou tvrdil, že generátor bol zasiaty z GetTickCount64. Žiadny riadok kódu to nikdy neurobil, čo z komentára urobilo jediné miesto, kde ten seed existoval:
// Ne-Windows vetva AesGenerateRandomBytes pred 3.114.8
// (komentár nad ňou sľuboval GetTickCount64 seed, ktorý sa nikdy neaplikoval)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
Postupnosť sa reštartuje s každým procesom a posúva sa v ňom, takže prvý dokument, ktorý ktorýkoľvek proces zašifruje, zdieľa file kľúč s prvým dokumentom každého iného procesu bežiaceho ten istý build, druhý s druhým a tak ďalej. File kľúč v R5, R6 a R7 nezávisí vôbec na hesle, keďže heslo ho len bali, čo znamená, že ktokoľvek, kto dokáže reprodukovať postupnosť, drží kľúč bez znalosti hesla. AESV4 pridáva druhé zlyhanie: ten istý kľúč s tým istým 8-bajtovým prefixom a počítadlom reštartujúcim na nule opakuje GCM nonces, čo NIST SP 800-38D §8 zakazuje bez výnimky. Opakovaný GCM nonce pod jedným kľúčom prezrádza XOR oboch plaintextov a vystavuje authentication subkey, takže tagy, na ktorých stojí AESV4-GCM šifrovanie a PDF MAC token, prestávajú znamenať čokoľvek. Dôvernosť aj integrita odchádzajú spolu
Rozsah je užší, než ten odstavec môže naznačovať. Windows buildy neboli nikdy dotknuté, pretože Windows vetva vždy volala CryptGenRandom cez advapi32 s CRYPT_VERIFYCONTEXT a vyhodila výnimku pri zlyhaní. Exponovaný bol výstup z non-Windows buildov starších než 3.114.8, čo v praxi znamená Lazarus a Free Pascal aplikácie na Linuxe a macOS, ďalšiu položku do zoznamu Delphi verzus FPC pascali v PDFium buildoch
Prečo nebolo Randomize nikdy to správne riešenie?
Zavolanie Randomize by schovalo symptóm bez opravy zdroja, pretože RandSeed je 32-bitová hodnota a Randomize ju odvodzuje z hodín. To stropuje počet možných key streamov na 2^32 a približná znalosť času zápisu súboru rezne hľadanie hlboko pod tú hranicu, čo je nič vedľa 256-bitového AES kľúča. Kľúčový materiál musí pochádzať z kernel entropy poolu, takže AesGenerateRandomBytes v 3.114.8 číta /dev/urandom, slučkuje cez krátke čítania a vyhodí výnimku, keď pool nedodá každý požadovaný bajt:
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; // zlyhanie alebo nečakaný koniec 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');
Odmietnutie je zámerné a sedí s tým, čo Windows vetva vždy robila, keď CryptGenRandom nie je dostupný. Zlyhané šifrované ukladanie je incident, ktorý si všimnete ten istý deň; úspešné ukladanie s predvídateľnými kľúčmi je také, o ktorom sa dozviete od niekoho iného. Vyplývajú z toho dva praktické dôsledky. Minimálny container alebo chroot bez naplneného /dev teraz zlyhá v šifrovaní namiesto tichého zhoršovania, takže ho namontujte. A keďže výnimka sa šíri von z TPdf.SaveAsEncrypted po tom, čo bol cieľový súbor otvorený s fmCreate, ostáva po nej prázdny výstupný súbor, ktorý má váš error handler zmazať
Prečo to round-trip testy nikdy nechytili?
Round-trip test nevidí konštantnú náhodu, pretože dešifrovanie obnoví akýkoľvek file kľúč, ktorý si šifrovanie vyberie. Test zašifruje dokument, znova ho otvorí s heslom, rozbalí kľúč z /UE a dešifruje každý objekt; predvídateľný kľúč sa rozbalí a dešifruje presne tak dobre ako náhodný a GCM tagy sa overia, pretože boli vypočítané tým istým kľúčom. Dokonca aj test, ktorý šifruje dvakrát a assertuje, že oba výstupy sú odlišné, prejde, keďže druhé volanie v tom istom procese čerpá ďalšie bajty postupnosti. Vlastnosť, na ktorej záleží — iný kľúč v každom procese — je pozorovateľná len porovnaním výstupu naprieč procesmi. Kedykoľvek ten istý kód hodnotu produkuje aj konzumuje, sú testy slepé k celým triedam defektov a náhoda je najčistejší príklad
Ako otestovať náhodu kľúčov naprieč procesmi?
Spustite malú sondu dvakrát ako oddelené procesy na cieľovej platforme a porovnajte výstup. Sonda nižšie volá DeriveEncryptionKeys a vypíše soľ uloženú v bajtoch 32 až 47 záznamu /U. Tá hodnota sa píše na očiach do každého zašifrovaného súboru, takže jej vypísanie do CI logov nič neprezradí, pritom pochádza od toho istého generátora ako file kľúč:
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-bajtový hash + 16-bajtová soľ
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // musí sa líšiť pri každom spustení
end.
Zapojte sondu do buildu pre každý non-Windows cieľ: spustite ju dvakrát, nech job zlyhá, ak sa riadky zhodnú. Rovnaké porovnanie funguje na súboroch už v obehu. Vezmite dva zašifrované PDF napísané rôznymi behmi tej istej aplikácie, prečítajte reťazce /U z ich Encrypt slovníkov a porovnajte posledných 16 bajtov; identické soli identifikujú dotknutý build a dokumenty sa majú zašifrovať znova z ich plaintextu s 3.114.8 alebo novšou, aby každý dostal čerstvý file kľúč. Všeobecný zvyk je precvičovať non-Windows code path na platforme samotnej namiesto dôvery behu na Windows, to isté uvažovanie stojí za libcurl timestamp backendom pre non-Windows buildy
PDFiumPas je PDF komponent pre Delphi a Lazarus postavený na engine PDFium, s AES-256, AES-GCM a PDF MAC tokenom implementovanými natívne v Pascale a kľúčovým materiálom čerpaným z generátora operačného systému na každej platforme. Detaily a sťahovanie sú na stránke PDFium Delphi component