Artykuł techniczny

Powtarzające się klucze PDF na FPC: poprawka RNG w PDFiumPas

Przed wersją 3.114.8 PDFiumPas generował materiał kluczy szyfrowania PDF na celach nie-Windowsowych funkcją Random z biblioteki runtime, a bo nic nie wołało Randomize, każdy proces produkował tę samą sekwencję bajtów. Buildy Free Pascala na Linuxie i macOS zapisywały więc identyczne klucze szyfrowania plików, sole, wektory CBC i prefiksy nonce AES-GCM, uruchomienie po uruchomieniu. Wersja 3.114.8 czyta zamiast tego /dev/urandom i rzuca wyjątek, gdy nie może

Sama wada to czterowierszowa pętla. Pożyteczniejsza lekcja brzmi: czemu zestaw testów, który szyfruje i deszyfruje setki dokumentów, z AESV3 i AESV4, z PDF MAC i bez, cały czas był zielony. Losowość stała per proces jest niewidzialna dla każdego testu, który biegnie wewnątrz jednego procesu — a dokładnie tak zwykle pisze się testy szyfrowania

Gdzie PDFiumPas potrzebuje losowych bajtów?

Każdy losowy bajt w stosie szyfrowania PDFiumPas pochodzi z pojedynczej procedury AesGenerateRandomBytes w jednostce FPdfAes, więc jeden zły organizm zakaża wszystko. Standardowy handler bezpieczeństwa z ISO 32000-2 §7.6.4 i rozszerzenie AESV4 z ISO/TS 32003 konsumują te bajty w tych miejscach:

  • 32-bajtowy klucz szyfrowania pliku, generowany od zera przez DeriveEncryptionKeys dla każdego dokumentu, a potem opakowywany w /UE i /OE pod kluczami wyprowadzonymi z hasła
  • Dwie 16-bajtowe sole: jedna w ostatnich 16 bajtach /U, druga w ostatnich 16 bajtach /O, każda podzielona na 8-bajtową sól walidacyjną i 8-bajtową sól klucza
  • Bajty od 12 do 15 tekstu jawnego za /Perms, które ISO 32000-2 wypełnia losowymi danymi, zanim blok zostanie zaszyfrowany kluczem pliku
  • 16-bajtowy wektor CBC doklejany przed każdym zaszyfrowanym stringiem i strumieniem w dokumencie AESV3
  • 8-bajtowy prefiks nonce dla dokumentów AESV4, po którym idzie 4-bajtowy licznik per obiekt startujący od zera
  • 32-bajtowa /KDFSalt i klucz MAC, gdy ustawione jest EnableIntegrityProtection
Każdy losowy bajt w stosie szyfrowania PDFiumPas płynie z AesGenerateRandomBytes w FPdfAes do sześciu konsumentów: 32-bajtowego klucza szyfrowania pliku opakowywanego w /UE i /OE, soli /U i /O, bajtów wypełnienia /Perms, wektora CBC AESV3, prefiksu nonce GCM AESV4 oraz soli KDF i klucza MAC
Jeden współdzielony generator znaczy, że jeden zły organizm zakaża materiał kluczy wszędzie naraz — dlatego poprawka wylądowała w pojedynczej procedurze, a nie przy każdym miejscu wywołania

Dlaczego każdy proces produkował ten sam klucz?

AesGenerateRandomBytes używał generatora systemowego tylko na Windows; wszędzie indziej wypełniał bufor z pseudolosowego generatora RTL, a ten startuje od RandSeed = 0, dopóki program nie woła Randomize. Komentarz nad pętlą twierdził, że generator jest zasiewany z GetTickCount64. Żadna linia kodu nigdy tego nie robiła, więc komentarz był jedynym miejscem, gdzie ziarno istniało:

// Gałąź nie-Windowsowa AesGenerateRandomBytes przed 3.114.8
// (komentarz nad nią obiecywał zasiew z GetTickCount64, którego nigdy nie zastosowano)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Sekwencja restartuje z każdym procesem i naprzód wewnątrz niego, więc pierwszy dokument, który cokolwiek zaszyfruje, dzieli klucz pliku z pierwszym dokumentem każdego innego procesu biegnącego na tym samym buildzie, drugi z drugim i tak dalej. Klucz pliku w R5, R6 i R7 w ogóle nie zależy od hasła, bo hasło go tylko opakowuje, więc każdy, kto potrafi odtworzyć sekwencję, trzyma klucz bez znajomości hasła. AESV4 dokłada drugą porażkę: ten sam klucz z tym samym 8-bajtowym prefiksem i licznikiem restartującym od zera powtarza nonce GCM, czego NIST SP 800-38D §8 wprost zabrania. Powtórzony nonce GCM pod jednym kluczem ujawnia XOR dwóch tekstów jawnych i wystawia podklucz uwierzytelniania, więc tagi, na których polega szyfrowanie AESV4-GCM i token PDF MAC, przestają cokolwiek znaczyć. Poufność i integralność odchodzą razem

Niezasiany generator RTL w PDFiumPas na buildach FPC poza Windows: przy RandSeed 0 każdy proces emituje tę samą sekwencję, więc dokument jeden w procesie A niesie ten sam klucz pliku co dokument jeden w procesie B, a AESV4 powtarza nonce GCM, bo ten sam klucz spotyka ten sam prefiks przy liczniku restartującym od zera
Bo klucz pliku nigdy nie zależy od hasła, każdy, kto potrafi odtworzyć sekwencję, trzyma klucz w całości, a powtórzone nonce GCM niszczą poufność i integralność razem

Zasięg jest węższy, niż sugeruje tamten akapit. Buildy Windows nigdy nie były dotknięte, bo gałąź Windows zawsze wołała CryptGenRandom przez advapi32 z CRYPT_VERIFYCONTEXT i rzucała, gdy to zawodziło. Wystawione było wyjście z buildów nie-Windowsowych starszych niż 3.114.8, co w praktyce znaczy aplikacje Lazarus i Free Pascal na Linuxie i macOS — jeden wpis więcej na listę pułapek Delphi kontra FPC w buildach PDFium

Dlaczego Randomize nigdy nie była właściwą poprawką?

Wołanie Randomize ukryłoby objaw bez naprawy źródła, bo RandSeed to wartość 32-bitowa, a Randomize wyprowadza ją z zegara. To zawęża liczbę możliwych strumieni kluczy do 2^32, a z grubsza znana pora zapisu pliku ścina wyszukiwanie daleko poniżej tego — obok 256-bitowego klucza AES to nic. Materiał kluczy musi pochodzić z puli entropii jądra, więc AesGenerateRandomBytes w 3.114.8 czyta /dev/urandom, obsługuje pętlą krótkie odczyty i rzuca, gdy pula nie dostarczy każdego żądanego 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;               // porażka albo nieoczekiwany koniec strumienia
    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');

Odmowa jest celowa i zgadza się z tym, co gałąź Windows zawsze robiła, gdy CryptGenRandom jest niedostępny. Nieudany zaszyfrowany zapis to incydent, który zauważysz tego samego dnia; udany zapis z przewidywalnymi kluczami to taki, o którym dowiesz się od kogoś innego. Płyną z tego dwie praktyczne konsekwencje. Minimalny kontener albo chroot bez wypełnionego /dev zawodzi teraz przy szyfrowaniu zamiast cicho się degradować, więc go zamontuj. A ponieważ wyjątek propaguje się na zewnątrz TPdf.SaveAsEncrypted po tym, jak plik docelowy został otwarty z fmCreate, zostaje po nim pusty plik wyjściowy do skasowania przez twój handler błędów

Dlaczego testy round-trip nigdy tego nie złapały?

Test round-trip nie widzi stałej losowości, bo deszyfrowanie odzyskuje każdy klucz pliku, jaki wybrało szyfrowanie. Test szyfruje dokument, otwiera go ponownie z hasłem, odpakowuje klucz z /UE i deszyfruje każdy obiekt; przewidywalny klucz odpakowuje i deszyfruje dokładnie tak dobrze jak losowy, a tagi GCM się weryfikują, bo zostały policzone tym samym kluczem. Nawet test, który szyfruje dwa razy i asertuje, że oba wyjścia się różnią, przechodzi, bo drugie wywołanie w tym samym procesie ciągnie kolejne bajty sekwencji. Właściwość, która się liczy — inny klucz w każdym procesie — jest obserwowalna tylko przez porównanie wyjść między procesami. Kiedy ten sam kod i produkuje, i konsumuje wartość, testy są ślepe na całe klasy wad, a losowość to ich najczystszy przykład

Jak przetestować losowość kluczy między procesami?

Uruchom małą sondę dwa razy jako osobne procesy na platformie docelowej i porównaj wyjście. Sonda poniżej woła DeriveEncryptionKeys i wypisuje sól zapisaną w bajtach od 32 do 47 wpisu /U. Ta wartość jest zapisana w jasny dzień w każdym zaszyfrowanym pliku, więc jej wypisanie w logach CI nic nie ujawnia, a pochodzi od tego samego generatora co klucz pliku:

Test losowości między procesami dla PDFiumPas: program SaltProbe woła DeriveEncryptionKeys i wypisuje hex bajtów /U od 32 do 47, zadanie uruchamia go dwa razy jako osobne procesy i zawodzi, gdy linie są identyczne, a wysłane PDF-y porównuje się po ostatnich 16 bajtach ich stringów /U
Stała losowość jest niewidzialna wewnątrz jednego procesu, bo deszyfrowanie odzyskuje każdy klucz, jaki wybrało szyfrowanie, więc właściwość, która się liczy, jest obserwowalna tylko przez porównanie wyjść między procesami
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-bajtowy hash + 16-bajtowa sól
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // musi się różnić przy każdym uruchomieniu
end.

Wpiń sondę w build dla każdego celu nie-Windowsowego: uruchom ją dwa razy, zawal zadanie, jeśli dwie linie się zgadzają. To samo porównanie działa na plikach już w obiegu. Weź dwa zaszyfrowane PDF-y zapisane przez różne uruchomienia tej samej aplikacji, odczytaj stringi /U z ich słowników Encrypt i porównaj ostatnie 16 bajtów; identyczne sole identyfikują dotknięty build, a dokumenty trzeba zaszyfrować ponownie z ich tekstu jawnego wersją 3.114.8 albo nowszą, żeby każdy dostał świeży klucz pliku. Ogólny nawyk to ćwiczenie ścieżek kodu nie-Windowsowych na samej platformie zamiast ufania uruchomieniu z Windows — to samo rozumowanie stoi za backendem znaczników czasu libcurl dla buildów nie-Windowsowych

PDFiumPas to komponent PDF dla Delphi i Lazarusa zbudowany na silniku PDFium, z AES-256, AES-GCM i tokenem PDF MAC zaimplementowanymi natywnie w Pascalu i materiałem kluczy czerpanym z generatora systemu operacyjnego na każdej platformie. Szczegóły i pobieranie są na stronie komponentu PDFium dla Delphi