Műszaki cikk

Ismétlődő PDF titkosítókulcsok FPC-n: PDFiumPas RNG-javítás

A 3.114.8-as verzió előtt a PDFiumPas a futtatókörnyezet Random függvényével termelte a PDF titkosítási kulcsanyagot a nem Windows célokon, és mivel senki sem hívta a Randomize-t, minden folyamat ugyanazt a bájtsorozatot állította elő. A Linux és macOS alatti Free Pascal buildek ezért futásról futásra azonos fájl-titkosítási kulcsokat, sókat, CBC IV-ket és AES-GCM nonce előtagokat írtak. A 3.114.8 ehelyett a /dev/urandom-ból olvas, és kivételt dob, ha nem tud

Maga a hiba egy négy soros ciklus. A hasznosabb tanulság az, miért maradt zölden az a tesztkészlet, amely AESV3-mal és AESV4-gyel, PDF MAC-kel és anélkül száznyi dokumentumot titkosít és visszafejt. A folyamatonkénti állandó véletlenszerűség minden, egyetlen folyamaton belül futó teszt elől láthatatlan, és pont így szokták megírni a titkosítási teszteket

Hol van szüksége a PDFiumPas-nak véletlen bájtokra?

A PDFiumPas titkosítási veremének minden véletlen bájta egyetlen eljárásból, az FPdfAes egység AesGenerateRandomBytes eljárásából érkezik, tehát egyetlen rossz forrás mindegyiket megfertőzi. Az ISO 32000-2 §7.6.4 szabványos biztonsági kezelője és az ISO/TS 32003 AESV4 kiterjesztése ezeken a helyeken fogyasztja ezeket a bájtokat:

  • A 32 bájtos fájl-titkosítási kulcs, amelyet a DeriveEncryptionKeys dokumentumonként frissen generál, majd jelszóból származtatott kulcsokkal csomagol a /UE-be és az /OE-be
  • Két 16 bájtos só, az egyik az /U utolsó 16 bájtjában, a másik az /O utolsó 16 bájtjában tárolódik, mindegyik 8 bájtos érvényesítő sóra és 8 bájtos kulcssóra oszlik
  • Az /Perms mögötti nyílt szöveg 12-től 15-ig terjedő bájtjai, amelyeket az ISO 32000-2 véletlen adattal tölt meg, mielőtt a blokk a fájlkulcs alatt titkosításra kerülne
  • Egy 16 bájtos CBC IV, amely minden titkosított string és stream elé kerül egy AESV3 dokumentumban
  • Egy 8 bájtos nonce előtag AESV4 dokumentumoknál, amelyet nulláról induló, objektonkénti 4 bájtos számláló követ
  • A 32 bájtos /KDFSalt és a MAC kulcs, ha az EnableIntegrityProtection be van kapcsolva
A PDFiumPas titkosítási veremének minden véletlen bájta az FPdfAes AesGenerateRandomBytes eljárásából hat fogyasztóba folyik: a /UE-be és /OE-be csomagolt 32 bájtos fájl-titkosítási kulcs, az /U és /O sók, az /Perms kitöltő bájtjai, az AESV3 CBC IV, az AESV4 GCM nonce előtag, valamint a KDF só és a MAC kulcs
Egyetlen közös generátor azt jelenti, hogy egyetlen rossz forrás egyszerre fertőzi meg a kulcsanyagot mindenhol, ezért landolt a javítás egyetlen eljárásban, nem hívóhelyenként

Miért termelt minden folyamat ugyanazt a kulcsot?

Az AesGenerateRandomBytes csak Windowson használta az operációs rendszer generátorát; minden másutt az RTL pszeudovéletlen generátorából töltötte a puffert, és az a generátor a RandSeed = 0 állapotból indul, hacsak a program nem hívja a Randomize-t. A ciklus feletti megjegyzés azt ígérte, hogy a generátor GetTickCount64-ből kap magot. Egyetlen kódsor sem tette meg soha, így a megjegyzés volt az egyetlen hely, ahol a mag egyáltalán létezett:

// Az AesGenerateRandomBytes nem Windows ága a 3.114.8 előtt
// (a felette lévő megjegyzés GetTickCount64 magot ígért, amelyet soha nem alkalmaztak)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

A sorozat folyamatonként újrakezdődik, és folyamaton belül halad előre, így bármely folyamat első dokumentuma megosztja a fájlkulcsát minden más, ugyanazt a buildet futtató folyamat első dokumentumával, a második a másodikkal, és így tovább. Az R5, R6 és R7 fájlkulcsa a jelszótól egyáltalán nem függ, hiszen a jelszó csak becsomagolja, ami azt jelenti, hogy bárki, aki képes reprodukálni a sorozatot, jelszó nélkül birtokolja a kulcsot. Az AESV4 második kudarcot is ad: ugyanaz a kulcs ugyanazzal a 8 bájtos előtaggal és nulláról újrainduló számlálóval GCM nonce-ket ismétel, amit a NIST SP 800-38D §8 kategorikusan tilt. Egy kulcs alatt ismétlődő GCM nonce felfedi a két nyílt szöveg XOR-ját, és kitárja a hitelesítő alikulcsot, így a AESV4-GCM titkosításra és a PDF MAC tokenre támaszkodó címkék megszűnnek bármit is jelenteni. A bizalmasság és az integritás egyszerre megy tönkre

Mag nélküli RTL generátor a PDFiumPas-ban nem Windows FPC buildeken: RandSeed 0 mellett minden folyamat ugyanazt a sorozatot bocsátja ki, így az A folyamat első dokumentuma ugyanazt a fájlkulcsot hordozza, mint a B folyamat első dokumentuma, az AESV4 pedig GCM nonce-ket ismétel, mert ugyanaz a kulcs találkozik ugyanazzal az előtaggal, miközben a számláló nulláról indul
Mivel a fájlkulcs soha nem függ a jelszótól, bárki, aki reprodukálni tudja a sorozatot, teljes birtokába jut a kulcsnak, az ismétlődő GCM nonce-ek pedig a bizalmasságot és az integritást együtt semmisítik meg

A hatókör szűkebb, mint amennyire az előző bekezdés sejtetni engedi. A Windows buildeket soha nem érintette, mert a Windows ág mindig is meghívta a CryptGenRandom-ot az advapi32-n keresztül CRYPT_VERIFYCONTEXT-tel, és kivételt dobott, ha ez elbukott. Ami kitett volt, az a 3.114.8-nál korábbi nem Windows buildek kimenete, ami a gyakorlatban Lazarus és Free Pascal alkalmazásokat jelent Linuxon és macOS-en, még egy bejegyzést a Delphi-FPC buktatók listájába PDFium buildeknél

Miért nem lehetett a Randomize a jó javítás?

A Randomize meghívása elrejtette volna a tünetet anélkül, hogy a forrást javította volna, mert a RandSeed egy 32 bites érték, és a Randomize az órából származtatja. Ez 2^32-re szorítja a lehetséges kulcsfolyamok számát, és ha nagyjából tudjuk, mikor íródott egy fájl, a keresés ettől jóval lejjebb szorul, ami semmi egy 256 bites AES kulcs mellett. A kulcsanyagnak a kernel entrópiakészletéből kell érkeznie, ezért a 3.114.8-ban az AesGenerateRandomBytes a /dev/urandom-ból olvas, rövid olvasásokon végigloopol, és kivételt dob, ha a készlet nem tud minden kért bájtot szállítani:

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;               // hiba vagy váratlan streamvég
    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');

Az elutasítás szándékos, és azt követi, amit a Windows ág mindig is tett, amikor a CryptGenRandom nem állt rendelkezésre. Egy elbukott titkosított mentés olyan incidens, amelyet még aznap észrevesz; egy sikeres, kiszámítható kulcsokkal írt mentés pedig olyan, amelyről mástól tud meg. Két gyakorlati következménye van. Egy minimális konténer vagy chroot, amelyben nincs feltöltött /dev, mostantól csendes romlás helyett elbukik a titkosítással, tehát mountolja fel. Mivel a kivétel a TPdf.SaveAsEncrypted-ből propagálódik ki azután, hogy a célfájl fmCreate-tel megnyílt, egy üres kimeneti fájl marad hátra, amelyet a hibakezelőjének törölnie kell

Miért nem csípte el soha a round-trip teszt?

Egy round-trip teszt nem látja az állandó véletlenszerűséget, mert a visszafejtés visszanyeri, amelyik fájlkulcsot a titkosítás választotta. A teszt titkosít egy dokumentumot, újra megnyitja a jelszóval, kicsomagolja a kulcsot az /UE-ből, és visszafejti az összes objektumot; egy kiszámítható kulcs pontosan olyan jól csomagol ki és fej vissza, mint egy véletlen, és a GCM címkék is átmentenek az ellenőrzésen, mert ugyanazzal a kulccsal számolták őket. Még az a teszt is átmegy, amely kétszer titkosít, és azt állítja, hogy a két kimenet eltér, hiszen ugyanazon folyamatban a második hívás a sorozat következő bájtjait húzza. A fontos tulajdonság — más kulcs minden folyamatban — csak folyamatok közötti kimenet-összehasonlításon figyelhető meg. Valahányszor ugyanaz a kód állítja elő és fogyasztja is az értéket, a tesztek teljes hibaosztályokra nézve vakok, és a véletlenszerűség a legtisztább példa erre

Hogyan tesztelheti a kulcsok véletlenszerűségét folyamatok között?

Futtasson egy kis szondát kétszer, külön folyamatokként a célplatformon, és hasonlítsa össze a kimenetet. Az alábbi szonda meghívja a DeriveEncryptionKeys-t, és kiírja az /U bejegyzés 32-től 47-ig terjedő bájtjaiban tárolt sót. Ez az érték szabad szemmel olvashatóan bekerül minden titkosított fájlba, tehát CI naplóba írni semmit sem fed fel, mégis ugyanabból a generátorból jön, mint a fájlkulcs:

Folyamatok közötti véletlenszerűségteszt a PDFiumPas-ra: a SaltProbe program meghívja a DeriveEncryptionKeys-t, és hexben kiírja az /U 32-től 47-ig terjedő bájtjait, a feladat kétszer futtatja külön folyamatokként, és elbukik, ha a sorok egyeznek, a kiszállított PDF-eket pedig az /U stringjeik utolsó 16 bájtján hasonlítják össze
Az állandó véletlenszerűség egy folyamaton belül láthatatlan, mert a visszafejtés visszanyeri, amelyik kulcsot a titkosítás választotta, így a fontos tulajdonság csak folyamatok közötti kimenet-összehasonlításon figyelhető meg
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 bájtos hash + 16 bájtos só
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // minden futáson el kell térnie
end.

Kösse be a szondát a buildbe minden nem Windows célra: futtassa kétszer, és buktassa a feladatot, ha a két sor egyezik. Ugyanez az összehasonlítás a forgalomban lévő fájlokon is működik. Vegyen két titkosított PDF-et, amelyeket ugyanannak az alkalmazásnak különböző futásai írtak, olvassa ki az /U stringeket az Encrypt szótáraikból, és hasonlítsa össze az utolsó 16 bájtjukat; az azonos sók érintett buildet jeleznek, és a dokumentumokat 3.114.8-as vagy újabb verzióval a nyílt szövegükből újra titkosítani kell, hogy mindegyik friss fájlkulcsot kapjon. Az általános szokás az, hogy a nem Windows kódutakat magán a platformon gyakoroltatjuk, nem a Windows futásra hagyatkozva — ugyanez a gondolat áll a libcurl időbélyeg-backend mögött nem Windows buildeknél

A PDFiumPas egy Delphi és Lazarus PDF komponens, amely a PDFium motorra épül, AES-256-tal, AES-GCM-mel és a PDF MAC tokennel natívan Pascalban implementálva, és a kulcsanyagot minden platformon az operációs rendszer generátorából meríti. Részletek és letöltések a PDFium Delphi komponens oldalán