Odborný článok

Reprodukovateľné PDF v Delphi: bajtovo zhodné uloženia

HotPDF Delphi Component produkuje bajtovo identický výstup PDF naprieč uloženiami, keď je vlastnosť ReproducibleOutput nastavená na True: pripne /CreationDate a /ModDate v Info na pevný dátum, nahradí identifikátor dokumentu odvodený od hodín seedovaným alebo z obsahu odvodeným hashom, dosadí konštanty za každý náhodný bajt, ktorý by inak AES šifrovacie cesty vyžrebovali, a zoradí každý slovník, ktorý serializuje. Príznak existuje pre regresné sady a porovnávanie build artefaktov, nie pre produkčné dokumenty, a dôvody tej hranice sú tá zaujímavá časť. Scenár, ktorý tú funkciu poháňa, je golden-file test. Vykreslíte faktúru, commitnete PDF a overíte, že zajtrajší build vyprodukuje tie isté bajty. Nikdy nevyprodukuje. Súbor sa v každom viewer-i otvorí v poriadku, text je identický, page tree je identický a diff aj tak svieti na štyroch či piatich miestach. Každý, kto skúsil postaviť PDF generátor pod bajtový regresný test, na tú stenu narazil, a opravou nie je "odstráň časové značky", ale presné zúčtovanie každého miesta, kde writer siaha po niečom inom než po samotnom dokumente

Prečo sa dve uloženia toho istého PDF líšia?

Dve uloženia toho istého dokumentu sa líšia preto, že PDF writer, vrátane HotPDF, siaha po štyroch zdrojoch entropie, ktoré s obsahom stránok nemajú nič spoločné: hodiny, identifikátor dokumentu, kryptografický generátor náhodných čísel a poradie položiek v slovníku v pamäti. Každý z nich je sám o sebe legitímny. ISO 32000-1 ich tam chce. Jednoducho robia zo súboru funkciu toho, kedy a kde bol zapísaný, namiesto toho, čo obsahuje

  • Hodiny. Slovník Info nesie /CreationDate a /ModDate (ISO 32000-1 §14.3.3, tabuľka 317) ako reťazce D:YYYYMMDDHHmmSS s príponou časového pásma (§7.9.4) a XMP paket opakuje ten istý okamih ako xmp:CreateDate a xmp:ModifyDate. HotPDF oba orazítkuje z FCreationDate, ktoré konstruktor inicializuje na Now, takže dve uloženia sa líšia v sekunde, v ktorej boli zapísané
  • Identifikátor. Pole /ID v traileri (ISO 32000-1 §14.4) drží trvalý identifikátor a identifikátor modifikácie. Predvolený recept HotPDF hashuje pre prvý element názov súboru spolu s aktuálnym časom až na milisekundy a pre druhý hashuje to plus GetTickCount. Dva identifikátory, dve čerstvé hodnoty pri každom behu
  • Náhodné bajty. Standard security závisí od identifikátora a od skutočnej náhodnosti. Pri AES-256 sa file encryption key, validačné soli aj kľúčové soli a každý CBC inicializačný vektor ťahajú zo systémového zdroja náhodnosti (ISO 32000-2 §7.6.4.4.7 vyžaduje náhodné soli). Keďže /U, /UE, /O a /OE sa všetky počítajú z tých bajtov, šifrovaný dokument sa zmení celý, aj keď sa plaintext nemení. Staršie algoritmy vkladajú prvý element /ID do kľúča (ISO 32000-1 §7.6.3.3, §7.6.3.4), takže samotný čerstvý identifikátor stačí na prekľúčovanie súboru
  • Poradie. PDF slovník je neusporiadané mapovanie a writer, ktorý prechádza svoj zoznam v pamäti, vypisuje kľúče v poradí vloženia. Každá cesta kódu, ktorá postaví resource slovník v inom slede, alebo načítaný dokument naparsovaný z iného rozloženia, vyprodukuje legálny, ale textovo odlišný súbor
Štyri zdroje entropie, pre ktoré sa dve uloženia jedného dokumentu v HotPDF líšia: FCreationDate orazítkované z Now napája dátumy D: aj XMP paket, /ID v traileri hashuje názov súboru, hodiny a GetTickCount, AES ťahá kľúčový materiál zo systémového zdroja náhodnosti a slovníky sa serializujú v poradí vloženia v pamäti
Každý zdroj je sám o sebe legitímny a ISO 32000-1 ich tam chce, a predsa spolu robia zo súboru funkciu toho, kedy a kde bol zapísaný, namiesto toho, čo obsahuje

Čo ReproducibleOutput pripne?

Nastavenie ReproducibleOutput := True pred BeginDoc alebo pred SaveLoadedDocument nahradí každý zo štyroch zdrojov pevnou hodnotou, a to v tých istých cestách kódu, ktoré by inak siahli po hodinách alebo po generátore náhodnosti, takže žiadny samostatný upratovací prechod netreba. Všimnite si, čo v tom zozname vyššie chýba: obsah. Fonty, page streamy, obrazové dáta a cross-reference tabuľka sú pre ten istý vstup už deterministické; šum žije výhradne v metadátach a v bezpečnostnej vrstve, a preto ho jedna cielená vlastnosť dokáže odstrániť. Vlastnosť má predvolene False a nič v knižnici vám ju nezapne

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

Vnútri BeginDoc reprodukovateľná vetva priradí FCreationDate := EncodeDate(2026, 1, 1) a identifikátor dokumentu nasype cez MD5CalcString('HotPDF-reproducible-seed') namiesto digestu z názvu súboru a hodín. To jedno priradenie pokrýva oba dátumy v Info aj oba dátumy v XMP, pretože všetky štyri sa renderujú z toho istého poľa. Keď sa súbor nakoniec zapisuje, BuildDocumentIdentifiers si pre identifikátor v traileri vypýta ComputeCanonicalDocumentIdentifier: ten vyexportuje celý objektový graf v kanonickom poradí, vynuluje číslice každého reťazca s dátumom D:, ktorý nájde, aby časové značky nemohli preniknúť späť cez hash, a z výsledku vezme MD5. Oba elementy /ID dostanú tú hodnotu. Ten istý z obsahu odvodený identifikátor sa použije, keď sa načítaný dokument šifruje bez toho, aby prešiel BeginDoc, čo je prípad ActivateProtection na súbore, ktorý ste otvorili cez LoadFromFile

Náhodné bajty sú tá najmenej zjavná substitúcia. Rutina pre AES-256 kľúč obalí svoj zdroj náhodnosti lokálnym helperom, ktorý pod príznakom zavolá FillChar(P^, Count, $5A) pre 32-bajtový file encryption key a pre každú 8-bajtovú soľ, a šifrátory reťazcov a streamov pre AES-128 aj AES-256 prepnú z AESGenerateRandomIV na AESGenerateStaticIV, ktorý inicializačný vektor pre slot I naplní hodnotou 14 * (1 + I). Keď sú kľúč, soli aj vektory všetky pevné, /U, /UE, /O, /OE a každý šifrovaný stream vyjdú pri druhom behu identické. Nakoniec SaveToStream zapne DeterministicDictionaryOrder vždy, keď je reprodukovateľný príznak nastavený, a serializér potom každý slovník pretriedi insertion sortom podľa surových bajtov názvov kľúčov, kratší prefix prvý, s pôvodným indexom ako rozbitím remízy. To je to isté poradie, aké používa diagnostický writer opísaný v článku o ručnej úprave PDF a jeho následnej oprave; reprodukovateľný príznak si požičiava len to poradie, nie zvyšok plain-textového rozloženia toho writera

Čo ReproducibleOutput pripína v HotPDF: dátum vytvorenia sa stane EncodeDate 2026, 1, 1, identifikátor v traileri príde z ComputeCanonicalDocumentIdentifier nad kanonickým grafom s vynulovanými číslicami v D:, AES kľúče a soli sa naplnia bajtmi $5A a AESGenerateStaticIV naplní každý slot, a DeterministicDictionaryOrder zoradí každý slovník
Substitúcie bežia v tých istých cestách kódu, ktoré by inak siahli po hodinách alebo po generátore náhodnosti, takže žiadny samostatný upratovací prechod netreba a oba elementy /ID dostanú tú istú z obsahu odvodenú hodnotu

Prečo pevný dátum aj tak prepúšťal hodiny?

Oprava vo v2.752.2 existuje preto, že pevný dátum vytvorenia sa pôvodne rozhodoval v konstruktore a konštruktor nemôže poznať vlastnosť, ktorú volajúci ešte nenastavil. Bežný sled volaní je Create, potom ReproducibleOutput := True a potom BeginDoc. V čase konštrukcie je FReproducibleOutput stále False, takže FCreationDate dostalo Now a to si podržalo. Identifikátor aj náhodné bajty boli pripnuté správne, takže dva súbory sa zhodovali takmer všade a líšili sa presne v dvoch dátumových reťazcoch a dvoch XMP poliach. Presun priradenia do reprodukovateľnej vetvy BeginDoc, vedľa seedovaného identifikátora, umiestnil rozhodnutie do bodu, kde má vlastnosť svoju konečnú hodnotu

Regresný test, ktorý to minul, má väčšiu cenu než samotná oprava. Dve uloženia, ktoré obe bežia v tej istej sekunde, zapíšu náhodou ten istý reťazec D: a bajtové porovnanie prejde pri chybe, ktorá zlyhá na každom pomalšom stroji. Opravený test spí 1100 ms medzi dvoma uloženiami, aby PDF timestamp zaručene prekročil hranicu sekundy, spúšťa prípad pre plain, AES-128 a AES-256 výstup so skutočnými heslami pri dvoch šifrovaných variantoch a porovnáva dva buffery cez CompareMem, pričom pri zlyhaní hlási prvý odlišný offset, aby diff ukázal na konkrétny objekt a nie na celý súbor. Bajtové porovnanie dokazuje determinizmus a nič iné, takže si nechajte samostatný assert, ktorý znovu načíta šifrovaný výstup s používateľským heslom a prečíta počet strán; zmena, ktorá spraví súbor stabilným a zároveň nečitateľným, nesmie prejsť len na základe zeleného diffu

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;

// v tele testu
A := SaveOnce(PathA);
TThread.Sleep(1100);          // vynúť inú sekundu PDF timestampu
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');

Je reprodukovateľné šifrované PDF stále bezpečné?

Nie. Dokument zašifrovaný pod ReproducibleOutput nie je chránený v žiadnom zmysluplnom zmysle a príznak musí byť vypnutý pre všetko, čo opustí testovací adresár. File encryption key pre AES-256 je tridsaťdva bajtov $5A, soli sú osem bajtov $5A a inicializačné vektory sledujú zverejnený aritmetický vzor. Heslo stále stráži obaly /UE a /OE, ale zabalený kľúč je konštanta, takže každý, kto tú konštantu pozná, dešifruje každý content stream úplne bez hesla. To, že soli sú pevné, zároveň odstraňuje jedinečnosť na úrovni dokumentu, na ktorú sa ISO 32000-2 §7.6.4.4.7 spolieha, aby rovnaké heslá nedávali naprieč súbormi rovnaké reťazce /U. Prečítajte si článok o nastavení AES-256, čo šifrovacie vlastnosti sľubujú, keď je zdroj náhodnosti neporušený; pod reprodukovateľným príznakom sú tie sľuby pozastavené

Kompromis pri identifikátore je jemnejší. ISO 32000-1 §14.4 zamýšľa, aby sa druhý element /ID menil pri každej modifikácii, aby nástroje vedeli odlíšiť aktualizovaný súbor od jeho predka, a reprodukovateľné uloženie zapisuje tú istú hodnotu do oboch slotov. Keďže tá hodnota je hash kanonického objektového grafu, dva dokumenty s odlišným obsahom dostanú stále odlišné identifikátory, čo je lepšie než konštanta. Ale seed, ktorý BeginDoc používa na odvodenie kľúča, je ten istý reťazec pre každý dokument na každom stroji, a čitateľ, ktorý sa na rozlíšenie súborov spolieha na /ID, napríklad cache anotácií alebo sidecar s údajmi formulára, zleje dokopy každý reprodukovateľný súbor, ktorý náhodou hashuje rovnako

Čo príznak nepokrýva?

ReproducibleOutput odstraňuje entropiu, ktorú zavádza sám writer; nevie odstrániť entropiu, ktorá prichádza z prostredia alebo cestami kódu, ktoré nekontroluje, a na troch z nich sa ľahko potknete

  • Prípona časového pásma. _DateTimeToPdfDate pripája lokálny UTC offset, takže D:20260101000000+08'00' na jednom build agentovi a D:20260101000000-05'00' na druhom sú pre ten istý pevný dátum odlišné bajty. Reprodukovateľnosť platí naprieč behmi na jednom stroji alebo naprieč strojmi, ktoré zdieľajú časové pásmo; pripnite pásmo agenta, ak vaše golden files cestujú
  • Inkrementálne aktualizácie. SaveIncrementalUpdate počíta svoj identifikátor modifikácie z cieľovej cesty, GetTickCount a aktuálneho času bez reprodukovateľnej vetvy, pretože inkrementálna sekcia je podľa definície nová modifikácia. Porovnávajte úplné prepisy, nie pripojené delty
  • Skratka s passthrough. SaveLoadedDocument bežne kopíruje nezmenený, nešifrovaný zdrojový súbor bajt po bajte namiesto jeho opätovnej serializácie. Reprodukovateľný príznak tú skratku vypne a vynúti úplný prepis, aby sa uplatnili pravidlá poradia a identifikátora, čo znamená, že reprodukovateľné uloženie načítaného súboru je pomalšie než predvolené a nikdy nie je kópiou vstupu. Porovnávajte ho s predchádzajúcim reprodukovateľným uložením, nikdy s originálom
Kde sa reprodukovateľné uloženia v HotPDF zastavia: _DateTimeToPdfDate stále pripája lokálny UTC offset, takže golden files sa líšia naprieč časovými pásmami, SaveIncrementalUpdate nemá reprodukovateľnú vetvu, pretože delta je nová modifikácia, a skratka s passthrough je vypnutá, takže načítaný súbor sa vždy prepíše úplne
Reprodukovateľnosť platí naprieč behmi na jednom stroji alebo naprieč strojmi, ktoré zdieľajú pásmo, a reprodukovateľné uloženie treba porovnávať s predchádzajúcim reprodukovateľným uložením, nikdy s pôvodným vstupom

Ešte jedna lekcia z toho istého release, o tom, čo prechádzajúca kontrola dokazuje a čo nie. Testovacia fixtura pre PDF/X-6 zavolala CharProcs.DeleteValue('A'), čím uvoľnila priamo držaný glyfový stream, a potom znovu vložila ten istý pointer, a oddelene odovzdala jeden priamy ExtGState objekt aj resource slovníku, aj patternu. Validátor konformity na tom use-after-free a dvojitom vlastníctve prechádzal stale, pretože čítal, čo uvoľnená pamäť náhodou obsahovala. Keď štrukturálna kontrola bliká, pozrite sa na vlastníctvo vstupu testu skôr, než sa pozriete na validátor. Reprodukovateľný výstup tú disciplínu zlacňuje: keď sú dve uloženia bajtovo identické, jediným zostávajúcim zdrojom blikania je samotný objektový graf a štrukturálny diff od katalógu nadol ho nájde

Vlastnosti ReproducibleOutput, DeterministicDictionaryOrder a šifrovanie opísané vyššie sú súčasťou štandardného HotPDF Delphi Component pre Delphi a C++Builder a ten istý príznak poháňa aj vlastný regresný korpus knižnice, takže správanie, ktoré dostanete v testovacej sade, je to správanie, s ktorým sa komponenta testuje