Artykuł techniczny

Powtarzalny PDF w Delphi: zapisy identyczne co do bajtu

Dwa PDF-y zapisane przez ten sam kod faktur w odstępie sekundy prawie nigdy nie są tym samym plikiem. Porównanie binarne najpierw potyka się w słowniku Info, gdzie /CreationDate przesunął się o sekundę, potem w pakiecie XMP, a następnie w trailerowym /ID. Włącz AES-256, a porównanie rozsypie się na prawie każdym strumieniu, bo sam klucz szyfrowania pliku został świeżo wygenerowany. Zamaskowanie dat przed porównaniem naprawia dwie pierwsze różnice i żadną z pozostałych. Test golden-file staje się godny zaufania dopiero wtedy, gdy każda wartość, którą writer bierze skądinąd niż z dokumentu, jest przypięta w miejscu, w którym jest czytana

Cztery wartości, które nigdy nie przyszły z dokumentu

HotPDF Delphi Component, jak każdy zgodny writer PDF, wypełnia cztery rodzaje wartości spoza zawartości strony: zegar, identyfikator dokumentu, kryptograficzne źródło losowe i kolejność w pamięci wpisów słownika. ISO 32000-1 ma powód dla każdego z nich

  • 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 tę samą chwilę jako xmp:CreateDate i xmp:ModifyDate. Wszystkie są renderowane z jednego znacznika czasu utworzenia, który komponent bierze z Now w chwili konstrukcji
  • Identyfikator. Trailerowa tablica /ID (§14.4) trzyma trwały identyfikator i identyfikator modyfikacji. Domyślnie pierwszy to MD5 z nazwy pliku plus bieżącego czasu z dokładnością do milisekundy, a drugi haszuje jeszcze raz tę wartość razem z licznikiem tików systemu
  • Losowe bajty. Dokument AES-256 potrzebuje 32-bajtowego klucza szyfrowania pliku, 8-bajtowej soli walidacyjnej i 8-bajtowej soli klucza dla /U, kolejnej pary dla /O, czterech bajtów wypełniacza wewnątrz /Perms oraz 16-bajtowego wektora inicjalizującego przed każdym szyfrowanym łańcuchem i strumieniem. ISO 32000-2 §7.6.4.4.7 chce, by sole były losowe. RC4 i AES-128 wyprowadzają klucz z haseł i pierwszego elementu /ID (ISO 32000-1 §7.6.3.3), więc sam świeży identyfikator wystarcza, by przekeyować cały plik
  • Kolejność. Słownik PDF to mapowanie bez uporządkowania (§7.3.7). Writer przechodzący po swojej liście w pamięci emituje klucze w kolejności, w jakiej kod zdarzył je dodać, więc dwie ścieżki kodu budujące ten sam słownik zasobów w innej kolejności dają legalny, lecz tekstowo różny plik
Cztery źródła entropii, przez które dwa zapisy HotPDF jednego dokumentu się różnią: znacznik czasu utworzenia brany z Now zasilający daty D: i pakiet XMP, trailerowe /ID haszujące nazwę pliku, zegar i licznik tików, AES czerpiący materiał klucza z systemowego źródła losowego oraz słowniki serializowane w kolejności wstawiania w pamięci
Każde źródło jest samo w sobie uprawnione i ISO 32000-1 ich tam chce, a jednak razem czynią plik funkcją tego, kiedy i gdzie był zapisany, a nie tego, co zawiera

Wszystko inne już wynika z wejścia: strumienie zawartości, skompresowane dane obrazów i offsety odwołań krzyżowych powtarzają się dla tych samych wywołań rysujących. Nawet jedno pole wyglądające losowo nie jest losowe: sześcioliterowy prefiks osadzonego podzbioru fontu (ISO 32000-1 §9.6.4, ta część ABCDEF+ w ABCDEF+Arial) jest liczony ze zbioru glifów, które podzbiór trzyma, więc ten sam tekst daje ten sam prefiks przy każdym uruchomieniu. Szum siedzi w metadanych i warstwie bezpieczeństwa — dlatego jedna właściwość potrafi go usunąć

Co przypina ReproducibleOutput

Ustawienie ReproducibleOutput := True przed BeginDoc zastępuje każde z czterech źródeł stałą wartością — w tych samych ścieżkach kodu, które inaczej sięgnęłyby po zegar albo generator losowy. Nie ma żadnego porządkującego przebiegu po fakcie. Właściwość domyślnie ma 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;     // musi być ustawione 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;

Pod flagą BeginDoc ustawia znacznik czasu utworzenia na 1 stycznia 2026, 00:00 czasu lokalnego, a ponieważ daty Info i daty XMP są renderowane z tej jednej wartości, jedno przypisanie pokrywa je wszystkie. Ta sama gałąź wysiewa wewnętrzny identyfikator dokumentu ze stałego łańcucha zamiast z nazwy pliku i zegara. Przy zapisie pliku trailerowe /ID jest liczone jako MD5 po kanonicznym eksporcie całego grafu obiektów, w którym najpierw wyzerowane są cyfry każdego łańcucha daty D:, więc znacznik czasu nie przemyca się z powrotem przez hasz. Oba elementy /ID dostają tę wartość. Wczytany dokument, który szyfrujesz bez wołania BeginDoc (ustaw ActivateProtection po LoadFromFile), dostaje ten sam identyfikator wyprowadzony z zawartości

Losowe bajty są zastępowane stałymi. Klucz pliku AES-256, wszystkie cztery sole i wypełniacz /Perms są wypełniane $5A, a każdy wektor CBC — dla łańcuchów i strumieni AES-128 tak samo jak AES-256 — staje się wzorem 14 * (1 + I) dla bajtu I. Ten wzór jest niezerowy celowo. Wektor samych zer to podręcznikowo słaby wybór dla CBC, a zestaw regresyjny nie ma biznesu w jego normalizowaniu. Przy przypiętych kluczu, solach i wektorach /U, /UE, /O, /OE, /Perms i każdy szyfrowany strumień wychodzą identycznie przy drugim uruchomieniu

Na koniec SaveToStream włącza DeterministicDictionaryOrder zawsze, gdy ustawiona jest flaga powtarzalności, i robi to, zanim wybierze między układem linearyzowanym, strumieniami obiektów i klasycznym, więc wyjście z LinearizeOutput albo UseObjectStreams też jest sortowane. Klucze są porządkowane po surowych bajtach, klucz będący prefiksem innego sortuje się pierwszy, a remisy rozstrzyga pierwotna pozycja. To porządek, którego używa writer diagnostyczny, opisany w artykule o ręcznej edycji PDF-a i naprawianiu go po fakcie; flaga powtarzalności pożycza porządek i nic z czysto tekstowego układu tamtego writera. Samodzielne włączenie DeterministicDictionaryOrder to półśrodek. Klucze się ustawią, ale daty Info i oba łańcuchy trailerowe /ID wciąż niosą zegar, więc dwa zapisy różnią się dalej w tych samych czterech miejscach

Co przypina ReproducibleOutput w HotPDF: data utworzenia staje się 1 stycznia 2026, identyfikator trailerowy to MD5 kanonicznego grafu obiektów z wyzerowanymi cyframi D:, klucze i sole AES wypełniają się bajtami $5A, każdy bajt IV podąża za 14 razy (1 + i), 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 niepotrzebny jest osobny przebieg porządkujący, a oba elementy /ID dostają tę samą wartość wyprowadzoną z zawartości

Test, który nie zaliczy się przez szczęście

Data jest przypinana w BeginDoc, bo konstruktor biegnie, zanim wywołujący zdąży ustawić właściwość. Normalna sekwencja to Create, potem ReproducibleOutput := True, potem BeginDoc — i tylko ostatnie widzi flagę w jej ostatecznej wartości. Ta kolejność ma bezpośrednią konsekwencję dla projektowania testów. Dwa zapisy wpadające w tę samą sekundę zegara ściennego piszą ten sam łańcuch D: przez przypadek, więc szybki test zalicza się niezależnie od przypięcia daty, a potem wysypuje się miesiące później na wolniejszym agencie builda

Poprawka jest tania: odczekaj 1100 ms między dwoma zapisami, żeby znacznik czasu na pewno przekroczył granicę sekundy, przepuść przypadek dla wyjścia zwykłego, AES-128 i AES-256 z prawdziwymi hasłami na wariantach szyfrowanych i zgłaszaj offset pierwszego różnego bajtu zamiast gołego true albo false. Offset wskazuje jeden obiekt. Wartość logiczna wskazuje cały plik

uses
  System.SysUtils, System.IOUtils, HPDFDoc;

function SaveOnce(const Target: string; Encrypt: Boolean;
  KeyType: THPDFKeyType): TBytes;
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.VerifySavedOutput := True;   // ścisłe ponowne wczytanie tuż po EndDoc
    if Encrypt then
    begin
      Pdf.OwnerPassword := 'owner';
      Pdf.UserPassword := 'user';
      Pdf.CryptKeyLength := KeyType; // aes128 albo aes256
      Pdf.ActivateProtection := True;
    end;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Result := TFile.ReadAllBytes(Target);
end;

// Twój własny pomocnik: -1 przy równości, w przeciwnym razie pierwszy różny offset
function FirstDifference(const A, B: TBytes): Integer;
var
  I, N: Integer;
begin
  N := Length(A);
  if Length(B) < N then
    N := Length(B);
  for I := 0 to N - 1 do
    if A[I] <> B[I] then
      Exit(I);
  if Length(A) <> Length(B) then
    Result := N
  else
    Result := -1;
end;

procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
  A, B: TBytes;
  Offset: Integer;
begin
  A := SaveOnce('run-a.pdf', Encrypt, KeyType);
  Sleep(1100);                     // wymuszenie innej sekundy znacznika czasu
  B := SaveOnce('run-b.pdf', Encrypt, KeyType);
  Offset := FirstDifference(A, B);
  if Offset >= 0 then
    raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;

Wołaj CheckReproducible(False, aes128), CheckReproducible(True, aes128) i CheckReproducible(True, aes256); typ klucza jest ignorowany przy przebiegu jasnym. VerifySavedOutput zasługuje tu na swoje miejsce. Po jego włączeniu EndDoc wczytuje ponownie plik, który właśnie zapisał, w świeżej instancji THotPDF z wyłączonym odzyskiwaniem odwołań krzyżowych, z hasłem użytkownika, gdy dokument jest chroniony, i podnosi wyjątek, jeśli wczytanie zawiedzie. Zmiana, która naraz czyni wyjście stabilnym i nieczytelnym, przepłynęłaby przez zielony diff; ponowne wczytanie ją zatrzymuje

Czy powtarzalny szyfrowany PDF wciąż jest bezpieczny?

Nie. Dokument zaszyfrowany pod ReproducibleOutput nie ma prawdziwej ochrony, a flaga należy do wyłączenia dla wszystkiego, co opuszcza katalog testów. Klucz pliku AES-256 to trzydzieści dwa bajty $5A. Hasło wciąż zamyka opakowania /UE i /OE, ale to, co opakowują, jest opublikowaną stałą, więc każdy, kto ją zna, odszyfruje każdy strumień zawartości bez hasła. Stałe sole usuwają też jednorazowość per plik, na której ISO 32000-2 §7.6.4.4.7 polega, by to samo hasło nie dało tego samego łańcucha /U w dwóch plikach. Artykuł o zestawie AES-256 opisuje, co właściwości szyfrowania dostarczają przy nietkniętym źródle losowym; pod flagą powtarzalności nic z tego nie obowiązuje. Zamykaj właściwość za wartością konfiguracyjną tylko dla testów, nie za ustawieniem, po które sięgnie ścieżka produkcyjna

AES-GCM jest obsłużony inaczej. Przy CryptKeyLength := aesgcm (tylko PDF 2.0) komponent czerpie 8-bajtowy prefiks nonce z systemowego źródła losowego, cokolwiek mówi ReproducibleOutput, a potem liczy w górę dla każdego szyfrowanego łańcucha albo strumienia. To właściwe zachowanie. GCM wygina się brzydko, gdy nonce się powtórzy pod tym samym kluczem: dwa szyfrogramy przeciekają XOR swoich tekstów jawnych, a atakujący odzyskuje dość, by podrobić tagi uwierzytelniające. Przypięty klucz plus przypięty nonce czyniłyby to powtórzenie pewnym w każdym pliku zestawu. Szyfrowane pliki golden używają więc aes128 albo aes256, które biegną w trybie CBC

Identyfikator to łagodniejszy kompromis. ISO 32000-1 §14.4 chce, by drugi element /ID zmieniał się przy każdej modyfikacji, a powtarzalny zapis wpisuje tę samą wartość w oba sloty. Ponieważ ta wartość haszuje graf obiektów, różna zawartość wciąż dostaje różne identyfikatory. Czytnik, który buduje cache na /ID — magazyn adnotacji albo boczny magazyn danych formularza — nadal potraktuje dwa powtarzalne zapisy tej samej zawartości jako jeden plik. W zestawie testów to dokładnie to, czego chcesz

Wejścia, które zostają poza writerem

ReproducibleOutput pokrywa entropię, którą writer wprowadza we własnym zakresie. Kilka wejść więcej przychodzi z maszyny builda albo z operacji, które z definicji zapisują nową chwilę

Gdzie zatrzymują się powtarzalne zapisy HotPDF: łańcuchy dat PDF wciąż niosą lokalny offset UTC, więc pliki golden różnią się między strefami czasowymi, SaveIncrementalUpdate buduje nowy identyfikator modyfikacji, bo delta to nowa modyfikacja, a skrót przelotowy jest wyłączony, więc wczytany plik jest zawsze w pełni przepisywany
Powtarzalność trzyma się między uruchomieniami na jednej maszynie albo między maszynami dzielącymi strefę, a powtarzalny zapis powinien być difowany względem poprzedniego powtarzalnego zapisu, nigdy względem pierwotnego wejścia
  • Przyrostek strefy czasowej. Każdy łańcuch daty PDF kończy się lokalnym offsetem UTC, więc ta sama przypięta data czyta się D:20260101000000+08'00' na jednym agencie builda i D:20260101000000-05'00' na innym. Pliki golden trzymają się między uruchomieniami na jednej maszynie albo między maszynami w jednej strefie. Jeśli pliki jeżdżą między biurami, przypnij strefę czasową agentów
  • Aktualizacje przyrostowe. SaveIncrementalUpdate zachowuje trwały identyfikator źródła i buduje nowy identyfikator modyfikacji z poprzedniego, ścieżki docelowej, licznika tików i bieżącego czasu. Dopisana sekcja to z definicji nowa modyfikacja. Porównuj pełne przepisy, nie dopisane delty
  • Skrót przelotowy. SaveLoadedDocument normalnie kopiuje niemodyfikowane, nieszyfrowane źródło bajt w bajt zamiast serializować je od nowa. Pod flagą plik jest zawsze przepisywany, by zastosowały się reguły porządku i identyfikatora, co czyni zapis wolniejszym i nigdy kopią wejścia. Diffuj go względem poprzedniego powtarzalnego zapisu, nigdy względem oryginału
  • Znaczniki czasu dodawane samodzielnie. AddDocumentAttachment stempluje /CreationDate i /ModDate w /Params osadzonego pliku (ISO 32000-1 §7.11.4) chwilą wywołania, a podpis zapisuje swój czas podpisania w /M. Te daty opisują zdarzenia, o które poprosił wywołujący, więc flaga je zostawia. Trzymaj załączniki i podpisy poza przypadkami bajtowymi albo maskuj te łańcuchy w porównaniu
  • Zainstalowane fonty. Podzbiór TrueType jest wycinany z pliku fontu zainstalowanego na maszynie, która biegnie test. Dwa agenty z różnymi wersjami Arial osadzą inne programy glifów dla tego samego tekstu, a prefiks podzbioru może się przy tym zmienić. Wysyłaj pliki fontów z obrazem builda

Wczytane dokumenty podlegają tym samym regułom. Ustaw właściwość przed wczytaniem, zrób edycje i zapisz przez SaveLoadedDocument; wyjście to znormalizowany przepis, który możesz zatwierdzić jako referencję dla następnego uruchomienia

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.ReproducibleOutput := True;   // ustaw przed wczytaniem
    if Pdf.LoadFromFile('incoming.pdf') <= 0 then
      raise Exception.Create('incoming.pdf did not load');
    // ... edycje objęte testem ...
    Pdf.SaveLoadedDocument('normalized.pdf');  // pełny przepis, posortowane klucze
  finally
    Pdf.Free;
  end;
end;

Zapisy identyczne co do bajtu zapędzają też w kąt migoczące sprawdzenie zgodności. Gdy writer jest stabilny, sprawdzenie, które zalicza się przy jednym uruchomieniu i obala przy następnym, reaguje na graf obiektów budowany przez test — na przykład jeden słownik bezpośredni podany dwóm rodzicom — więc obejrzyj wejście testu, zanim ruszy walidator. Diff strukturalny od katalogu w dół pokazuje, który obiekt się przeniósł między dwoma uruchomieniami

ReproducibleOutput, DeterministicDictionaryOrder, VerifySavedOutput i właściwości szyfrowania użyte wyżej są częścią HotPDF Delphi Component, który dostarczany jest dla Delphi i C++Builder