Technický článek

Opakované PDF šifrovací klíče na FPC: oprava RNG v PDFiumPas

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
Každý náhodný byte v šifrovacím stacku PDFiumPas teče z AesGenerateRandomBytes v FPdfAes do šesti konzumentů: 32bytový file encryption klíč zabalený do /UE a /OE, soli /U a /O, padding byty /Perms, CBC IV AESV3, GCM nonce prefix AESV4 a KDF sůl a MAC klíč
Jeden sdílený generátor znamená, že jeden špatný zdroj kontaminuje klíčový materiál všude najednou, a proto oprava dopadla do jediné procedury místo na každé volací místo

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ě

Nezasetý RTL generátor v PDFiumPas na non-Windows FPC buildech: s RandSeed 0 emituje každý proces tutéž sekvenci, takže dokument jedna v procesu A nese tentýž file klíč jako dokument jedna v procesu B a AESV4 opakuje GCM nonce, protože tentýž klíč potká tentýž prefix s counterem restartujícím se na nule
Protože file klíč nikdy nezávisí na hesle, kdokoli kdo umí reprodukovat sekvenci, drží klíč rovnou celý a opakované GCM nonce ničí důvěrnost a integritu dohromady

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íč:

Test náhodnosti napříč procesy pro PDFiumPas: program SaltProbe volá DeriveEncryptionKeys a vypíše hex bytů /U 32 až 47, job ho spustí dvakrát jako oddělené procesy a selže, když se řádky rovnají, a odeslané PDF se porovnávají podle posledních 16 bytů svých stringů /U
Konstantní náhodnost je uvnitř jednoho procesu neviditelná, protože dešifrování vytáhne jakýkoli klíč, který si šifrování vybralo, takže vlastnost, na které záleží, je pozorovatelná jen porovnáním výstupu napříč procesy
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