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
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
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:
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