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
/CreationDatei/ModDate(ISO 32000-1 §14.3.3, tabela 317) jako łańcuchyD:YYYYMMDDHHmmSSz przyrostkiem strefy czasowej (§7.9.4), a pakiet XMP powtarza tę samą chwilę jakoxmp:CreateDateixmp:ModifyDate. Wszystkie są renderowane z jednego znacznika czasu utworzenia, który komponent bierze zNoww 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/Permsoraz 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
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
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ę
- 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 iD: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.
SaveIncrementalUpdatezachowuje 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.
SaveLoadedDocumentnormalnie 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.
AddDocumentAttachmentstempluje/CreationDatei/ModDatew/Paramsosadzonego 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