Artykuł techniczny

Powtarzalny PDF w Delphi: zapisy identyczne co do bajtu

HotPDF Delphi Component produkuje identyczne co do bajtu wyjście PDF między zapisami, gdy właściwość ReproducibleOutput ma wartość True: przypina /CreationDate i /ModDate w Info do stałej daty, zastępuje zegarowy identyfikator dokumentu hashem z ziarna albo z treści, podstawia stałe za każdy losowy bajt, który ścieżki szyfrowania AES wzięłyby skądinąd, i sortuje każdy serializowany słownik. Flaga istnieje dla zestawów regresyjnych i porównywania artefaktów budowania, a nie dla dokumentów produkcyjnych, i powody tej granicy są tu najciekawsze. Scenariuszem, który napędza tę funkcję, jest test golden file. Renderujesz fakturę, commitujesz PDF i wymagasz, żeby jutrzejszy build wyprodukował te same bajty. Nigdy tego nie robi. Plik otwiera się bez problemu w każdym czytniku, tekst jest identyczny, drzewo stron identyczne, a diff i tak zapala się w czterech czy pięciu miejscach. Każdy, kto próbował poddać generator PDF regresji na poziomie bajtów, trafił na tę ścianę, a poprawką nie jest usunięcie znaczników czasu, tylko dokładne rozliczenie każdego miejsca, w którym writer sięga po coś innego niż sam dokument

Dlaczego dwa zapisy tego samego PDF-a się różnią?

Dwa zapisy tego samego dokumentu różnią się, bo writer PDF, HotPDF też, sięga po cztery źródła entropii, które nie mają nic wspólnego z treścią stron: zegar systemowy, identyfikator dokumentu, kryptograficzny generator liczb losowych i kolejność wpisów w pamięci. Każde z nich jest samo w sobie uzasadnione. ISO 32000-1 chce ich tam mieć. One po prostu czynią plik funkcją tego, kiedy i gdzie został zapisany, a nie tego, co zawiera

  • Zegar. Słownik Info niesie /CreationDate i /ModDate (ISO 32000-1 §14.3.3, Tabela 317) jako łańcuchy D:YYYYMMDDHHmmSS z przyrostkiem strefy czasowej (§7.9.4), a pakiet XMP powtarza ten sam moment jako xmp:CreateDate i xmp:ModifyDate. HotPDF stempluje oba z FCreationDate, które konstruktor inicjalizuje na Now, więc dwa zapisy różnią się sekundą, w której powstały
  • Identyfikator. Tablica /ID w trailerze (ISO 32000-1 §14.4) trzyma identyfikator stały i identyfikator modyfikacji. Domyślna recepta HotPDF hashuje dla pierwszego elementu nazwę pliku razem z bieżącym czasem co do milisekundy, a dla drugiego hashuje tamto plus GetTickCount. Dwa identyfikatory, dwie świeże wartości przy każdym uruchomieniu
  • Losowe bajty. Standardowe zabezpieczenia zależą od identyfikatora i od prawdziwej losowości. Dla AES-256 klucz szyfrowania pliku, sole walidacji i klucza oraz każdy wektor inicjalizujący CBC pochodzą z systemowego źródła losowości (ISO 32000-2 §7.6.4.4.7 wymaga losowych soli). Ponieważ /U, /UE, /O i /OE są liczone z tych bajtów, zaszyfrowany dokument zmienia się w całości, nawet gdy jawny tekst nie. Starsze algorytmy wpinają pierwszy element /ID do klucza (ISO 32000-1 §7.6.3.3, §7.6.3.4), więc sam świeży identyfikator wystarcza, żeby przekluczyć plik
  • Kolejność. Słownik PDF to mapowanie bez kolejności, a writer, który przechodzi swoją listę w pamięci, wypisuje klucze w kolejności wstawiania. Każda ścieżka kodu budująca słownik zasobów w innej kolejności albo wczytany dokument sparsowany z innego układu daje plik zgodny z prawem, ale tekstowo inny
Cztery źródła entropii, przez które dwa zapisy HotPDF tego samego dokumentu się różnią: FCreationDate stemplowane z Now zasila daty D: i pakiet XMP, /ID w trailerze hashuje nazwę pliku, zegar i GetTickCount, AES czerpie materiał klucza z systemowego źródła losowości, a słowniki serializują się w kolejności wstawiania do pamięci
Każde źródło jest samo w sobie uzasadnione i ISO 32000-1 chce ich tam mieć, ale razem czynią plik funkcją tego, kiedy i gdzie został zapisany, a nie tego, co zawiera

Co przypina ReproducibleOutput?

Ustawienie ReproducibleOutput := True przed BeginDoc albo przed SaveLoadedDocument zastępuje każde z tych czterech źródeł stałą wartością i robi to w tych samych ścieżkach kodu, które inaczej sięgnęłyby po zegar albo po generator losowy, więc osobny przebieg sprzątania nie jest potrzebny. Zauważ, czego na tej liście nie ma: treści. Fonty, strumienie stron, dane obrazów i tablica odsyłaczy są już deterministyczne dla tego samego wejścia; szum mieszka wyłącznie w metadanych i w warstwie zabezpieczeń, i dlatego jedna celowa właściwość potrafi go usunąć. Właściwość domyślnie ma wartość False i nic w bibliotece nie włącza jej za ciebie

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'golden-invoice.pdf';
    Pdf.ReproducibleOutput := True;     // przed BeginDoc
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Wewnątrz BeginDoc gałąź powtarzalna przypisuje FCreationDate := EncodeDate(2026, 1, 1) i zasiewa identyfikator dokumentu przez MD5CalcString('HotPDF-reproducible-seed') zamiast skrótu z nazwy pliku i zegara. To jedno przypisanie pokrywa obie daty w Info i obie daty w XMP, bo wszystkie cztery są renderowane z tego samego pola. Gdy plik jest wreszcie zapisywany, BuildDocumentIdentifiers pyta ComputeCanonicalDocumentIdentifier o identyfikator do trailera: eksportuje cały graf obiektów w porządku kanonicznym, zeruje cyfry każdego znalezionego łańcucha daty D:, żeby znaczniki czasu nie przeciekły z powrotem przez hash, i bierze MD5 z wyniku. Oba elementy /ID dostają tę wartość. Ten sam identyfikator wyprowadzony z treści jest używany, gdy wczytany dokument jest szyfrowany bez przechodzenia przez BeginDoc, co ma miejsce przy ActivateProtection na pliku otwartym przez LoadFromFile

Losowe bajty to najmniej oczywiste podstawienie. Procedura klucza AES-256 opakowuje swoje źródło losowości w lokalny helper, który pod flagą woła FillChar(P^, Count, $5A) dla 32-bajtowego klucza szyfrowania pliku i dla każdej 8-bajtowej soli, a szyfratory łańcuchów i strumieni AES-128 oraz AES-256 przełączają się z AESGenerateRandomIV na AESGenerateStaticIV, które wypełnia wektor inicjalizujący wartością 14 * (1 + I) dla slotu I. Z kluczem, solami i wektorami ustalonymi na stałe /U, /UE, /O, /OE i każdy zaszyfrowany strumień wychodzą identyczne przy drugim uruchomieniu. Na koniec SaveToStream włącza DeterministicDictionaryOrder, gdy tylko ustawiona jest flaga powtarzalności, a serializer sortuje wtedy każdy słownik przez sortowanie wstawianiem po surowych bajtach nazw kluczy, krótszy prefiks pierwszy, z pierwotnym indeksem jako rozstrzygnięciem remisu. To ta sama kolejność, której używa writer diagnostyczny opisany w artykule o ręcznej edycji PDF i naprawianiu go potem; flaga powtarzalności pożycza tylko kolejność, a nie resztę tekstowego układu tamtego writera

Co przypina ReproducibleOutput w HotPDF: data utworzenia staje się EncodeDate 2026, 1, 1, identyfikator do trailera pochodzi z ComputeCanonicalDocumentIdentifier po kanonicznym grafie z wyzerowanymi cyframi dat D:, klucze i sole AES wypełniają się bajtami $5A, AESGenerateStaticIV wypełnia każdy slot, a DeterministicDictionaryOrder sortuje każdy słownik
Podstawienia biegną w tych samych ścieżkach kodu, które inaczej sięgnęłyby po zegar albo generator losowy, więc osobny przebieg sprzątania nie jest potrzebny, a oba elementy /ID dostają tę samą wartość wyprowadzoną z treści

Dlaczego stała data wciąż przeciekała zegar systemowy?

Poprawka w v2.752.2 istnieje, bo stała data utworzenia była początkowo ustalana w konstruktorze, a konstruktor nie może znać właściwości, której wywołujący jeszcze nie ustawił. Normalna kolejność wywołań to Create, potem ReproducibleOutput := True, potem BeginDoc. W chwili konstruowania FReproducibleOutput jest jeszcze False, więc FCreationDate dostało Now i tak zostało. Identyfikator i losowe bajty były przypięte poprawnie, więc oba pliki zgadzały się prawie wszędzie i różniły się dokładnie w dwóch łańcuchach dat i dwóch polach XMP. Przeniesienie przypisania do gałęzi powtarzalnej w BeginDoc, obok zasianego identyfikatora, postawiło decyzję w miejscu, w którym właściwość ma już swoją ostateczną wartość

Test regresyjny, który to przegapił, jest wart więcej niż sama poprawka. Dwa zapisy uruchomione w tej samej sekundzie zegara zapisują ten sam łańcuch D: przypadkiem, a porównanie bajtów przechodzi dla błędu, który polegnie na każdej wolniejszej maszynie. Poprawiony test śpi 1100 ms między dwoma zapisami, żeby znacznik czasu PDF na pewno przekroczył granicę sekundy, uruchamia przypadek dla wyjścia jawnego, AES-128 i AES-256 z prawdziwymi hasłami przy dwóch wariantach zaszyfrowanych i porównuje oba bufory przez CompareMem, raportując przy niepowodzeniu pierwsze różniące się przesunięcie, żeby diff wskazywał konkretny obiekt, a nie cały plik. Porównanie bajtów dowodzi determinizmu i niczego więcej, więc trzymaj osobne sprawdzenie, które wczytuje zaszyfrowane wyjście hasłem użytkownika i czyta liczbę stron; zmiana, która czyni plik stabilnym i nieczytelnym naraz, nie może przejść siłą zielonego diffa

function SaveOnce(const Target: string): TBytes;
var
  Pdf: THotPDF;
  Stream: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.CryptKeyLength := aes256;
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, Stream.Size);
    if Stream.Size > 0 then
      Stream.ReadBuffer(Result[0], Stream.Size);
  finally
    Stream.Free;
  end;
end;

// w ciele testu
A := SaveOnce(PathA);
TThread.Sleep(1100);          // wymuś inną sekundę znacznika czasu PDF
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
  'two saves under ReproducibleOutput must be byte-identical');

Czy powtarzalny zaszyfrowany PDF jest nadal bezpieczny?

Nie. Dokument zaszyfrowany z włączonym ReproducibleOutput nie jest chroniony w żadnym sensownym znaczeniu, a flaga musi być wyłączona dla wszystkiego, co wychodzi z katalogu testowego. Klucz szyfrowania pliku AES-256 to trzydzieści dwa bajty $5A, sole to osiem bajtów $5A, a wektory inicjalizujące idą opublikowanym wzorem arytmetycznym. Hasło nadal bramkuje opakowania /UE i /OE, ale opakowany klucz jest stałą, więc każdy, kto tę stałą zna, odszyfruje każdy strumień treści bez żadnego hasła. Ustalone sole usuwają też niepowtarzalność na dokument, na której ISO 32000-2 §7.6.4.4.7 opiera się, żeby identyczne hasła nie dawały identycznych łańcuchów /U w różnych plikach. Przeczytaj artykuł o konfiguracji AES-256, żeby zobaczyć, co właściwości szyfrowania obiecują, gdy źródło losowości jest nienaruszone; pod flagą powtarzalności te obietnice są zawieszone

Kompromis wokół identyfikatora jest subtelniejszy. ISO 32000-1 §14.4 zakłada, że drugi element /ID zmienia się przy każdej modyfikacji, żeby narzędzia odróżniły zaktualizowany plik od jego przodka, a zapis powtarzalny wpisuje tę samą wartość w oba sloty. Ponieważ ta wartość jest hashem kanonicznego grafu obiektów, dwa dokumenty o różnej treści nadal dostają różne identyfikatory, co jest lepsze niż stała. Ale ziarno, którego BeginDoc używa do wyprowadzenia klucza, to ten sam łańcuch dla każdego dokumentu na każdej maszynie, a czytelnik, który rozróżnia pliki po /ID, na przykład cache adnotacji albo plik towarzyszący z danymi formularza, pomiesza ze sobą każdy powtarzalny plik, który akurat zahashuje się tak samo

Czego flaga nie obejmuje?

ReproducibleOutput usuwa entropię, którą writer wprowadza sam; nie potrafi usunąć entropii wchodzącej przez środowisko albo przez ścieżki kodu, których nie kontroluje, a na trzy z nich łatwo się potknąć

  • Przyrostek strefy czasowej. _DateTimeToPdfDate dopisuje lokalne przesunięcie UTC, więc D:20260101000000+08'00' na jednym agencie budującym i D:20260101000000-05'00' na innym to różne bajty dla tej samej ustalonej daty. Powtarzalność zachodzi między uruchomieniami na jednej maszynie albo między maszynami dzielącymi strefę czasową; przypnij strefę agenta, jeśli twoje golden file podróżują
  • Aktualizacje przyrostowe. SaveIncrementalUpdate liczy swój identyfikator modyfikacji ze ścieżki docelowej, GetTickCount i bieżącego czasu, bez żadnej gałęzi powtarzalnej, bo sekcja przyrostowa jest z definicji nową modyfikacją. Porównuj pełne przepisania, a nie dopisane delty
  • Skrót przepuszczający. SaveLoadedDocument normalnie kopiuje niezmodyfikowany, niezaszyfrowany plik źródłowy bajt po bajcie, zamiast serializować go od nowa. Flaga powtarzalności wyłącza ten skrót i wymusza pełne przepisanie, żeby zadziałały reguły kolejności i identyfikatora, co znaczy, że powtarzalny zapis wczytanego pliku jest wolniejszy od domyślnego i nigdy nie jest kopią wejścia. Porównuj go z poprzednim zapisem powtarzalnym, nigdy z oryginałem
Gdzie kończą się powtarzalne zapisy HotPDF: _DateTimeToPdfDate nadal dopisuje lokalne przesunięcie UTC, więc golden file różnią się między strefami czasowymi, SaveIncrementalUpdate nie ma gałęzi powtarzalnej, bo delta jest nową modyfikacją, a skrót przepuszczający jest wyłączony, więc wczytany plik jest zawsze przepisywany w całości
Powtarzalność zachodzi między uruchomieniami na jednej maszynie albo między maszynami dzielącymi strefę, a powtarzalny zapis należy porównywać z poprzednim zapisem powtarzalnym, nigdy z pierwotnym wejściem

Jeszcze jedna lekcja z tego samego wydania, o tym, co przechodzące sprawdzenie dowodzi, a czego nie. Fixture testowy PDF/X-6 wołał CharProcs.DeleteValue('A'), co zwalniało trzymany bezpośrednio strumień glifów, a potem wstawiał ten sam wskaźnik z powrotem, i osobno przekazywał jeden bezpośredni obiekt ExtGState zarówno do słownika zasobów, jak i do wzoru. Walidator zgodności przechodził na tym use-after-free i podwójnej własności raz na jakiś czas, bo czytał to, co zwolniona pamięć akurat trzymała. Kiedy sprawdzenie strukturalne miga, popatrz na własność danych wejściowych testu, zanim popatrzysz na walidator. Powtarzalne wyjście czyni tę dyscyplinę tańszą: gdy dwa zapisy są identyczne co do bajtu, jedynym pozostałym źródłem migotania jest sam graf obiektów, a diff strukturalny od katalogu w dół je znajdzie

Opisane tu właściwości ReproducibleOutput, DeterministicDictionaryOrder i właściwości szyfrowania są w standardowym HotPDF Delphi Component dla Delphi i C++Buildera, a ta sama flaga napędza własny korpus regresyjny biblioteki, więc zachowanie, które dostajesz w zestawie testów, jest zachowaniem, z którym komponent jest testowany