Tehnični članak

Ponavljanje ključev PDF na FPC: popravek RNG v PDFiumPas

Pred različico 3.114.8 je PDFiumPas material ključev za šifriranje PDF na ciljih izven Windows ustvarjal s funkcijo Random iz izvajalne knjižnice, in ker nič ni poklicalo Randomize, je vsak proces proizvedel isto zaporedje bajtov. Gradnje Free Pascal na Linuxu in macOS so torej zapisovale identične ključe za šifriranje datotek, soli, IV CBC in predpone nonce AES-GCM, zagon za zagonom. Različica 3.114.8 namesto tega bere /dev/urandom in sproži izjemo, kadar tega ne more

Napaka sama je zanka s štirimi vrsticami. Koristnejša lekcija je, zakaj je testna zbirka, ki šifrira in dešifrira stotine dokumentov, z AESV3 in AESV4, s PDF MAC in brez, ves čas ostala zelena. Naključnost, konstantna na proces, je nevidna vsakemu preizkusu, ki teče znotraj enega procesa — in točno tako se po navadi pišejo testi šifriranja

Kje PDFiumPas potrebuje naključne bajte?

Vsak naključni bajt v skladu za šifriranje PDFiumPas prihaja iz enega samega postopka, AesGenerateRandomBytes v enoti FPdfAes, zato en slab vir onesnaži vse skupaj. Standardni varnostni upravljalnik v ISO 32000-2 §7.6.4 in razširitev AESV4 v ISO/TS 32003 porabijo te bajte na teh mestih:

  • 32-bajtni ključ za šifriranje datoteke, ki ga DeriveEncryptionKeys za vsak dokument ustvari na sveže in ga potem ovije v /UE in /OE pod ključi, izpelanimi iz gesla
  • Dve 16-bajtni soli, ena shranjena v zadnjih 16 bajtih /U in ena v zadnjih 16 bajtih /O, vsaka razdeljena na 8-bajtno validacijsko sol in 8-bajtno ključno sol
  • Bajti 12 do 15 čistega besedila za /Perms, ki jih ISO 32000-2 zapolni z naključnimi podatki, preden je blok šifriran pod ključem datoteke
  • 16-bajtni IV CBC, postavljen pred vsak šifriran niz in tok v dokumentu AESV3
  • 8-bajtna predpona nonce za dokumente AESV4, ki ji sledi 4-bajtni števec na objekt, ki se začne z nič
  • 32-bajtni /KDFSalt in ključ MAC, kadar je nastavljen EnableIntegrityProtection
Vsak naključni bajt v skladu za šifriranje PDFiumPas priteče iz AesGenerateRandomBytes v FPdfAes k šestim potrošnikom: 32-bajtni ključ za šifriranje datoteke, ovit v /UE in /OE, soli /U in /O, bajti polnila /Perms, IV CBC AESV3, predpona nonce GCM AESV4 ter KDF sol in ključ MAC
Enoten generator pomeni, da en slab vir hkrati onesnaži ključni material povsod, zato je popravek pristal v enem samem postopku in ne na vsakem klicnem mestu

Zakaj je vsak proces proizvedel isti ključ?

AesGenerateRandomBytes je generator operacijskega sistema uporabljal le na Windows; povsod drugje je zapolnil medpomnilnik iz psevdonaključnega generatorja RTL, ta pa se začne z RandSeed = 0, če program ne pokliče Randomize. Komentar nad zanko je trdil, da je bil generator oskrbljen s semenom iz GetTickCount64. Nobena vrstica kode tega nikoli ni naredila, zato je bil komentar edino mesto, kjer je seme sploh obstajalo:

// Ne-Windows veja AesGenerateRandomBytes pred 3.114.8
// (komentar nad njo je obljubil seme GetTickCount64, ki ga ni nikoli dobila)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Zaporedje se ponovno začne pri vsakem procesu in v njem napreduje, zato prvi dokument, ki ga kateri koli proces šifrira, deli svoj ključ datoteke s prvim dokumentom vsakega drugega procesa, ki poganja isto gradnjo, drugi z drugim in tako naprej. Ključ datoteke v R5, R6 in R7 sploh ni odvisen od gesla, saj ga geslo le ovije, kar pomeni, da kdor zmore ponoviti zaporedje, drži ključ v rokah brez poznavanja gesla. AESV4 doda drugo odpoved: isti ključ z isto 8-bajtno predpono in števecem, ki se ponovno začne z nič, ponavlja nece GCM, kar NIST SP 800-38D §8 prepoveduje brez oklevanja. Ponovljen nonce GCM pod istim ključem razkrije XOR obeh čistih besedil in izpostavi podključ za overjanje, zato oznake, na katere se zanaša šifriranje AESV4-GCM in žeton PDF MAC, nehajo pomeniti karkoli. Zaupnost in celovitost odideta hkrati

Neosemenjen generator RTL v PDFiumPas na gradnjah FPC izven Windows: z RandSeed 0 vsak proces izda isto zaporedje, zato dokument ena v procesu A nosi isti ključ datoteke kot dokument ena v procesu B, AESV4 pa ponavlja nece GCM, ker isti ključ sreča isto predpono, števec pa se znova začne z nič
Ker ključ datoteke nikoli ni odvisen od gesla, kdor zmore ponoviti zaporedje, drži ključ v celoti, ponovljeni nonci GCM pa skupaj uničita zaupnost in celovitost

Obseg je ožji, kot bi lahko nakazal ta odstavek. Gradnje Windows nikoli niso bile prizadete, ker veja Windows vedno pokliče CryptGenRandom prek advapi32 s CRYPT_VERIFYCONTEXT in sproži izjemo, kadar to spodleti. Izpostavljen je bil izhod ne-Windows gradnji, starejših od 3.114.8, kar v praksi pomeni aplikacije Lazarus in Free Pascal na Linuxu in macOS — še en vnos na seznam pasti Delphi proti FPC v gradnjah PDFium

Zakaj Randomize nikoli ni bila prava rešitev?

Klic Randomize bi skril simptom, ne da bi popravil vir, ker je RandSeed 32-bitna vrednost, Randomize pa jo izpelje iz ure. To število možnih ključnih tokov omeji na 2^32, poznavanje približnega trenutka nastanka datoteke pa iskanje zreže daleč pod to — ob 256-bitnem ključu AES to ni nič. Ključni material mora prihajati iz bazena entropije jedra, zato AesGenerateRandomBytes v 3.114.8 bere /dev/urandom, obrača zanko čez kratke bralne operacije in sproži izjemo, kadar bazen ne more dostaviti vsakega zahtevanega bajta:

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;               // napaka ali nepričakovan konec toka
    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');

Zavrnitev je namerna in se ujema s tem, kar je veja Windows vedno naredila, kadar CryptGenRandom ni na voljo. Spodletelo šifrirano shranjevanje je primer, ki ga opazite isti dan; uspešno shranjevanje s predvidljivimi ključi pa je tisto, o katerem izveste od nekoga drugega. Sledita dve praktični posledici. Minimalen vsebnik ali chroot brez napolnjenega /dev zdaj spodleti pri šifriranju, namesto da bi tiho degradiral, zato ga priklopite. In ker se izjema razširi ven iz TPdf.SaveAsEncrypted potem, ko je bila ciljna datoteka odprta s fmCreate, za sabo pusti prazno izhodno datoteko, ki jo naj Vaš error handler izbriše

Zakaj round-trip testi tega nikoli niso ujeli?

Round-trip preizkus konstantne naključnosti ne vidi, ker dešifriranje povrne kateri koli ključ datoteke, ki ga je šifriranje izbralo. Preizkus dokument zašifrira, ga znova odpre z geslom, razvije ključ iz /UE in dešifrira vsak objekt; predvidljiv ključ se razvije in dešifrira prav tako dobro kot naključen, oznake GCM pa se potrdijo, ker so bile izračunane z istim ključem. Celo preizkus, ki šifrira dvakrat in zagotovi, da se izhoda razlikujeta, gre skozi, ker drugi klic v istem procesu potegne naslednje bajte zaporedja. Lastnost, ki šteje — drug ključ v vsakem procesu — je opazovljiva le s primerjavo izhodov čez procese. Kadar koli ista koda tako proizvaja kot porablja vrednost, so testi slepi za cele razrede napak, naključnost pa je najčistejši primer

Kako preizkusite naključnost ključev čez procese?

Poženite majhno sondo dvakrat kot ločena procesa na ciljni platformi in primerjajte izhod. Sonda spodaj pokliče DeriveEncryptionKeys in izpiše sol, shranjeno v bajtih 32 do 47 vnosa /U. Ta vrednost je vidno zapisana v vsako šifrirano datoteko, izpis v dnevnikih CI torej ne razkrije ničesar, prihaja pa iz istega generatorja kot ključ datoteke:

Preizkus naključnosti čez procese za PDFiumPas: program SaltProbe pokliče DeriveEncryptionKeys in izpiše hex bajtov 32 do 47 /U, opravilo ga požene dvakrat kot ločena procesa in odpove, ko se vrstici ujemata, poslani PDF pa se primerjajo po zadnjih 16 bajtih njihovih nizov /U
Konstantna naključnost je znotraj enega procesa nevidna, ker dešifriranje povrne kateri koli ključ, ki ga je izbralo šifriranje, zato je lastnost, ki šteje, opazovljiva le s primerjavo izhodov čez procese
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-bajtni hash + 16-bajtna sol
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // se mora razlikovati pri vsakem zagonu
end.

Priključite sondo v gradnjo za vsak cilj izven Windows: poženite jo dvakrat in spodletite opravilo, če se vrstici ujemata. Ista primerjava deluje na datotekah, ki so že v obtoku. Vzemite dva šifrirana PDF, napisana ob različnih zagonih iste aplikacije, preberite niza /U iz njunih slovarjev Encrypt in primerjajte zadnjih 16 bajtov; identični soli prepoznavata prizadeto gradnjo, dokumenta pa ju je treba znova zašifrirati iz čistega besedila s 3.114.8 ali novejšo, tako da vsak dobi svež ključ datoteke. Splošna navada naj bo, da ne-Windows poti kode vadite na sami platformi, namesto da zaupate zagonu Windows — isto razmišljanje stoji za časovnim zaledjem libcurl za gradnje izven Windows

PDFiumPas je komponenta PDF za Delphi in Lazarus, zgrajena na pogonu PDFium, z AES-256, AES-GCM in žetonom PDF MAC, implementiranim izvorno v Pascalu, ključni material pa se vleče iz generatorja operacijskega sistema na vsaki platformi. Podrobnosti in prenosi so na strani komponente PDFium za Delphi