Odborný článok

Opakované PDF šifrovacie kľúče na FPC: oprava RNG PDFiumPas

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
Každý náhodný bajt v encryption stacku PDFiumPas tečie z AesGenerateRandomBytes vo FPdfAes do šiestich konzumentov: 32-bajtový file encryption key zabalený do /UE a /OE, soli /U a /O, padding bajty /Perms, CBC IV AESV3, GCM nonce prefix AESV4 a KDF soľ s MAC kľúčom
Jeden zdieľaný generátor znamená, že jeden zlý zdroj kontaminuje kľúčový materiál všade naraz, a preto oprava pristála v jedinej procedúre, nie na každom call site

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

Nezasiatý RTL generátor v PDFiumPas na FPC buildoch mimo Windows: s RandSeed 0 emituje každý proces tú istú postupnosť, takže dokument jedna v procese A nesie ten istý file kľúč ako dokument jedna v procese B a AESV4 opakuje GCM nonces, pretože ten istý kľúč stretáva ten istý prefix s počítadlom reštartujúcim na nule
Keďže file kľúč nikdy nezávisí na hesle, ktokoľvek, kto dokáže reprodukovať postupnosť, drží kľúč rovno a opakované GCM nonces ničia dôvernosť a integritu 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ľúč:

Test náhody naprieč procesmi pre PDFiumPas: program SaltProbe volá DeriveEncryptionKeys a vypíše hex /U bajtov 32 až 47, job ho spustí dvakrát ako oddelené procesy a zlyhá, keď sa riadky zhodnú, a odoslané PDF sa porovnávajú podľa posledných 16 bajtov svojich reťazcov /U
Konštantná náhoda je neviditeľná vnútri jedného procesu, pretože dešifrovanie obnoví akýkoľvek kľúč, ktorý si šifrovanie vyberie, takže podstatná vlastnosť je pozorovateľná len porovnaním výstupu naprieč procesmi
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