Artykuł techniczny

Czemu klucze szyfrowania PDF potrzebują /dev/urandom na FPC

Generowanie materiału kluczy szyfrowania PDF funkcją Random z biblioteki runtime to pułapka na Free Pascalu: dopóki cokolwiek nie woła Randomize, każdy proces produkuje tę samą sekwencję bajtów, więc buildy na Linuxie i macOS zapisywałyby identyczne klucze szyfrowania plików, sole, wektory CBC i prefiksy nonce AES-GCM, uruchomienie po uruchomieniu. PDFiumPas czyta zamiast tego /dev/urandom na celach nie-Windowsowych i rzuca wyjątek, gdy nie może

Sama pułapka mieści się w czterowierszowej pętli. Pożyteczniejsza lekcja brzmi: czemu zestaw testów szyfrowania, AESV3 i AESV4, z PDF MAC i bez, może pozostać zielony tuż nad nią. Losowość stała per proces jest niewidzialna dla każdego testu, który biegnie wewnątrz jednego procesu — a dokładnie tak zwykle organizuje się zestawy testów 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łby materiał kluczy wszędzie naraz, a jeden dobry pokrywa każde miejsce wywołania

Dlaczego każdy proces miałby produkować ten sam klucz?

Pseudolosowy generator RTL startuje od RandSeed = 0, dopóki program nie woła Randomize, więc gałąź nie-Windowsowa, która wypełnia z niego bufor klucza, dostaje tę samą sekwencję w każdym procesie. Komentarz twierdzący, że generator jest zasiewany z GetTickCount64, nie pomaga, gdy żadna linia kodu tego faktycznie nie robi; komentarz staje się jedynym miejscem, gdzie ziarno istnieje:

// Pułapka: niezasiany generator RTL jako źródło klucza
// (komentarz obiecujący zasiew z GetTickCount64 nie stosuje żadnego)
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ż mogłoby sugerować tamten akapit. Na Windows pytanie nie powstaje, bo gałąź Windows woła CryptGenRandom przez advapi32 z CRYPT_VERIFYCONTEXT i rzuca, gdy to zawodzi. Pułapka jest swoista dla celów nie-Windowsowych, 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 nie jest odpowiedzią?

Wołanie Randomize ukrywa 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 czyta /dev/urandom, przechodzi pętlą po krótkich odczytach 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 robi gałąź Windows, 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 przy szyfrowaniu zamiast cicho się degradować, więc zamontuj go. 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

Czemu testy round-trip tego nie łapią?

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 znaczą, że źródło klucza jest przewidywalne, a te dokumenty trzeba zaszyfrować ponownie z ich tekstu jawnego, ż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