Tehnički članak

Ponovljeni PDF ključevi šifriranja na FPC: PDFiumPas RNG fix

Prije verzije 3.114.8 PDFiumPas je na ne-Windows metama generirao materijal PDF ključeva šifriranja funkcijom Random runtime biblioteke, i budući da ništa nije zvalo Randomize, svaki je proces proizvodio isti niz bajtova. Free Pascal buildovi na Linuxu i macOS-u zato su pisali identične ključeve šifriranja datoteka, soli, CBC IV-ove i AES-GCM nonce prefikse pokretanje za pokretanjem. Verzija 3.114.8 umjesto toga čita /dev/urandom i diže iznimku kad ne može

Sam defekt petlja je od četiri retka. Korisnija lekcija je zašto je test suite koji šifrira i dešifrira stotine dokumenata, s AESV3 i AESV4, s PDF MAC-om i bez njega, cijelo vrijeme ostao zelen. Slučajnost konstantna po procesu nevidljiva je svakom testu koji radi unutar jednog procesa, i upravo se tako obično pišu testovi šifriranja

Gdje PDFiumPas treba slučajne bajtove?

Svaki slučajan bajt u PDFiumPas stogu šifriranja dolazi iz jedne procedure, AesGenerateRandomBytes u jedinici FPdfAes, pa jedan loš izvor kontaminira sve. Standardni sigurnosni handler u ISO 32000-2 §7.6.4 i AESV4 proširenje u ISO/TS 32003 troše te bajtove na ovim mjestima:

  • Ključ šifriranja datoteke od 32 bajta, svježe generiran s DeriveEncryptionKeys za svaki dokument i zatim umotan u /UE i /OE pod ključevima izvedenim iz lozinke
  • Dvije soli od 16 bajtova, jedna pohranjena u zadnjih 16 bajtova /U i jedna u zadnjih 16 bajtova /O, svaka podijeljena na validacijsku so od 8 bajtova i ključnu so od 8 bajtova
  • Bajtovi 12 do 15 otvorenog teksta iza /Perms, koje ISO 32000-2 puni slučajnim podacima prije nego se blok šifrira pod ključem datoteke
  • CBC IV od 16 bajtova dodan ispred svakog šifriranog stringa i streama u AESV3 dokumentu
  • Nonce prefiks od 8 bajtova za AESV4 dokumente, iza kojeg slijedi brojač po objektu od 4 bajta koji kreće od nule
  • /KDFSalt od 32 bajta i MAC ključ kad je EnableIntegrityProtection postavljeno
Svaki slučajan bajt u PDFiumPas stogu šifriranja teče iz AesGenerateRandomBytes u FPdfAes u šest potrošača: ključ šifriranja datoteke od 32 bajta umotan u /UE i /OE, soli /U i /O, bajtovi popune /Perms, AESV3 CBC IV, AESV4 GCM nonce prefiks, te KDF so i MAC ključ
Jedan dijeljeni generator znači da jedan loš izvor kontaminira ključni materijal svugdje odjednom, zato je popravak sletio u jednu proceduru umjesto na svako mjesto poziva

Zašto je svaki proces proizvodio isti ključ?

AesGenerateRandomBytes koristio je generator operativnog sustava samo na Windowsu; svugdje drugdje punio je međuspremnik iz RTL pseudo-slučajnog generatora, a taj generator kreće od RandSeed = 0 osim ako program zove Randomize. Komentar iznad petlje tvrdio je da se generator sije iz GetTickCount64. Nijedan redak koda to nikad nije učinio, pa je komentar bio jedino mjesto na kojem je sjeme postojalo:

// Ne-Windows grana AesGenerateRandomBytes prije 3.114.8
// (komentar iznad nje obećavao je GetTickCount64 sjeme koje nikad nije primijenjeno)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Slijed restartira sa svakim procesom i napreduje unutar njega, pa prvi dokument koji bilo koji proces šifrira diji svoj ključ datoteke s prvim dokumentom svakog drugog procesa koji izvodi isti build, drugi s drugim, i tako dalje. Ključ datoteke u R5, R6 i R7 uopće ne ovisi o lozinci, jer lozinka samo umotava ključ, što znači da svatko tko može reproducirati slijed drži ključ a da ne zna lozinku. AESV4 dodaje drugi kvar: isti ključ s istim prefiksom od 8 bajtova i brojačem koji restartira od nule ponavlja GCM nonces, što NIST SP 800-38D §8 izričito zabranjuje. Ponovljeni GCM nonce pod jednim ključem otkriva XOR dva otvorena teksta i izlaže autentikacijski podključ, pa oznake na kojima počiva AESV4-GCM šifriranje i PDF MAC token prestaju značiti išta. Povjerljivost i integritet nestaju istovremeno

Nesjemenjeni RTL generator u PDFiumPasu na ne-Windows FPC buildovima: s RandSeed 0 svaki proces ispisuje isti slijed, pa dokument jedan u procesu A nosi isti ključ datoteke kao dokument jedan u procesu B, a AESV4 ponavlja GCM nonces jer isti ključ susreće isti prefiks s brojačem koji restartira od nule
Budući da ključ datoteke nikad ne ovisi o lozinci, svatko tko može reproducirati slijed drži ključ odmah, a ponovljeni GCM nonces razaruju povjerljivost i integritet zajedno

Opseg je uži nego što bi taj odlomak mogao nagovijestiti. Windows buildovi nikad nisu bili pogođeni, jer Windows grana oduvijek zove CryptGenRandom kroz advapi32 s CRYPT_VERIFYCONTEXT i diže iznimku kad to padne. Izložen je bio izlaz ne-Windows buildova starijih od 3.114.8, što u praksi znači Lazarus i Free Pascal aplikacije na Linuxu i macOS-u, još jedan unos na popis Delphi naspram FPC zamki u PDFium buildovima

Zašto Randomize nikad nije bio pravi popravak?

Zvati Randomize skrilo bi simptom ne popravivši izvor, jer je RandSeed 32-bitna vrijednost i Randomize je izvodi iz sata. To ograničava broj mogućih ključnih slijedova na 2^32, a otprilike poznato vrijeme nastanka datoteke pretragu spušta daleko ispod te granice, što je ništavno uz 256-bitni AES ključ. Ključni materijal mora dolaziti iz kernelnog bazena entropije, pa AesGenerateRandomBytes u 3.114.8 čita /dev/urandom, vrti se preko kratkih čitanja i diže iznimku ako bazen ne može isporučiti svaki traženi 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;               // kvar ili neočekivani kraj streama
    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');

Odbijanje je namjerno i poklapa se s onim što je Windows grana oduvijek radila kad CryptGenRandom nije dostupan. Neuspjelo šifrirano spremanje incident je koji primijetite isti dan; uspješno spremanje s predvidivim ključevima ono je o kojem saznate od nekog drugog. Slijede dvije praktične posljedice. Minimalni kontejner ili chroot bez napunjenog /dev sada pada na šifriranju umjesto da tiho degradira, pa ga mountirajte. I budući da iznimka propagira van iz TPdf.SaveAsEncrypted nakon što je ciljna datoteka otvorena s fmCreate, ostaje prazna izlazna datoteka koju Vaš error handler treba obrisati

Zašto round trip testovi nikad nisu uhvatili?

Round trip test ne može vidjeti konstantnu slučajnost, jer dešifriranje povrati kakav god ključ datoteke šifriranje odabralo. Test šifrira dokument, otvara ga opet s lozinkom, odmotava ključ iz /UE i dešifrira svaki objekt; predvidiv ključ odmotava i dešifrira jednako dobro kao slučajan, a GCM oznake verificiraju se jer su izračunate istim tim ključem. Čak i test koji šifrira dvaput i tvrdi da se dva izlaza razlikuju prolazi, jer drugi poziv u istom procesu crta sljedeće bajtove slijeda. Svojstvo koje je bitno, drugačiji ključ u svakom procesu, vidljivo je jedino usporedbom izlaza preko procesa. Kad isti kod i proizvodi i troši vrijednost, testovi su slijepi za cijele klase defekata, a slučajnost najčistiji je primjer

Kako testirati slučajnost ključeva preko procesa?

Pokrenite mali probni program dvaput kao odvojene procese na ciljnoj platformi i usporedite izlaz. Probni program dolje zove DeriveEncryptionKeys i ispisuje so pohranjenu u bajtovima 32 do 47 unosa /U. Ta se vrijednost piše na vidnome mjestu u svaku šifriranu datoteku, pa je ispis u CI logovima ništa ne otkriva, a dolazi iz istog generatora kao ključ datoteke:

Test slučajnosti preko procesa za PDFiumPas: program SaltProbe zove DeriveEncryptionKeys i ispisuje hex bajtova 32 do 47 od /U, posao ga pokreće dvaput kao odvojene procese i pada kad se recenzi poklope, a isporučeni PDF-ovi uspoređuju se po zadnjih 16 bajtova svojih /U stringova
Konstantna slučajnost nevidljiva je unutar jednog procesa jer dešifriranje povrati kakav god ključ šifriranje odabralo, pa je svojstvo koje je bitno vidljivo jedino usporedbom izlaza preko procesa
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 so
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // mora se razlikovati pri svakom pokretanju
end.

Ugradite probni program u build za svaku ne-Windows metu: pokrenite ga dvaput, oborite posao ako se dva retka poklope. Ista usporedba radi i nad datotekama koje već kruže. Uzmite dva šifrirana PDF-a napisana različitim pokretanjima iste aplikacije, pročitajte /U stringove iz njihovih Encrypt rječnika i usporedite zadnjih 16 bajtova; identične soli identificiraju pogođeni build, a dokumente treba ponovno šifrirati iz njihovog otvorenog teksta s 3.114.8 ili novijom tako da svaki dobije svjež ključ datoteke. Opća navika jest vježbati ne-Windows putanje koda na samoj platformi umjesto vjerovati Windows pokretanju, isti razlog iza libcurl timestamp backenda za ne-Windows buildove

PDFiumPas je Delphi i Lazarus PDF komponenta građena na PDFium engineu, s AES-256, AES-GCM i PDF MAC tokenom implementiranim izvorno u Pascalu i ključnim materijalom crpanim iz generatora operativnog sustava na svakoj platformi. Detalji i preuzimanja na stranici PDFium Delphi komponente