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