Tehnični članak

Ponovljiv izpis PDF v Delphiju: bajtno enaka shranjevanja

HotPDF Delphi Component daje bajtno enak izhod PDF skozi shranjevanja, kadar je lastnost ReproducibleOutput nastavljena na True: datum /CreationDate in /ModDate v slovarju Info pritrdi na fiksni datum, identifikator dokumenta iz stenske ure nadomesti z zgoščeno vrednostjo iz semena ali iz vsebine, vsak naključni bajt, ki bi ga poti šifriranja AES sicer črpale, nadomesti s konstanto in vsak slovar, ki ga serializira, razvrsti. Zastavica obstaja za regresijske nize in primerjavo izdelkov gradnje, ne za dokumente v proizvodnji, in razlogi za to mejo so zanimivi del. Scenarij, ki je sprožil to funkcijo, je test zlatega izhoda. Upodobite račun, potrdite PDF v sistemu za nadzor različic in zahtevajte, da jutrišnja gradnja da iste bajte. Nikoli jih ne da. Datoteka se v vsakem pregledovalniku lepo odpre, besedilo je enako, drevo strani je enako, primerjava pa se vseeno zasveti na štirih ali petih mestih. Vsakdo, ki je skušal generator PDF postaviti pod regresijski test na ravni bajtov, je naletel na to steno, popravek pa ni »odstrani časovne žige«, ampak natančen obračun vsakega mesta, kjer pisec pogleda kam drugam kot v sam dokument

Zakaj se dve shranjevanji istega PDF razlikujeta?

Dve shranjevanji istega dokumenta se razlikujeta, ker pisec PDF, tudi HotPDF, pogleda v štiri vire entropije, ki nimajo nobene zveze z vsebino strani: stensko uro, identifikator dokumenta, kriptografski generator naključnih števil in vrstni red vnosov v slovarju v pomnilniku. Vsak od njih je sam po sebi upravičen. ISO 32000-1 jih hoče tam. Preprosto naredijo datoteko funkcijo tega, kdaj in kje je bila zapisana, namesto tega, kaj vsebuje

  • Ura. Slovar Info nosi /CreationDate in /ModDate (ISO 32000-1 §14.3.3, tabela 317) kot niza D:YYYYMMDDHHmmSS s pripono časovnega pasu (§7.9.4), paket XMP pa isti trenutek ponovi kot xmp:CreateDate in xmp:ModifyDate. HotPDF oba odtisne iz FCreationDate, ki ga konstruktor inicializira na Now, zato se shranjevanji razlikujeta v sekundi, v kateri sta bili zapisani
  • Identifikator. Niz /ID v repu (ISO 32000-1 §14.4) hrani trajni identifikator in identifikator spremembe. HotPDF-ov privzeti recept za prvi element zgošča ime datoteke skupaj s trenutnim časom do milisekunde, za drugega pa zgošča to skupaj z GetTickCount. Dva identifikatorja, dve sveži vrednosti ob vsakem zagonu
  • Naključni bajti. Standardna zaščita je odvisna od identifikatorja in od resnične naključnosti. Pri AES-256 se ključ za šifriranje datoteke, soli za overjanje in ključ ter vsak inicializacijski vektor CBC črpajo iz sistemskega vira naključnosti (ISO 32000-2 §7.6.4.4.7 zahteva naključne soli). Ker so /U, /UE, /O in /OE vsi izračunani iz teh bajtov, se šifriran dokument spremeni v celoti, tudi kadar se čisto besedilo ne. Starejši algoritmi prvi element /ID vpnejo v ključ (ISO 32000-1 §7.6.3.3, §7.6.3.4), zato je svež identifikator sam dovolj, da se ključ datoteke zamenja
  • Vrstni red. Slovar PDF je neurejena preslikava in pisec, ki hodi po svojem seznamu v pomnilniku, izda ključe v vrstnem redu vstavljanja. Vsaka pot kode, ki slovar virov zgradi v drugačnem zaporedju, ali naložen dokument, razčlenjen iz drugačne postavitve, da zakonito, a besedilno drugačno datoteko
Štirje viri entropije, zaradi katerih se dve shranjevanji enega dokumenta s HotPDF razlikujeta: FCreationDate, odtisnjen iz Now, napaja datume D: in paket XMP, /ID v repu zgošča ime datoteke, uro in GetTickCount, AES črpa ključni material iz sistemskega vira naključnosti, slovarji pa se serializirajo v vrstnem redu vstavljanja v pomnilniku
Vsak vir je sam po sebi upravičen in ISO 32000-1 jih hoče tam, skupaj pa naredijo datoteko funkcijo tega, kdaj in kje je bila zapisana, namesto tega, kaj vsebuje

Kaj ReproducibleOutput pritrdi?

Nastavitev ReproducibleOutput := True pred BeginDoc ali pred SaveLoadedDocument vsakega od štirih virov nadomesti s fiksno vrednostjo, in to v istih poteh kode, ki bi sicer segle po uri ali po generatorju naključij, zato ločen prehod čiščenja ni potreben. Opazite, kaj na zgornjem seznamu manjka: vsebina. Pisave, tokovi strani, podatki slik in tabela medsebojnih sklicev so za isti vhod že deterministični; šum živi izključno v metapodatkih in v varnostni plasti, zato ga lahko odstrani ena ciljna lastnost. Lastnost je privzeto False in nič v knjižnici je ne vklopi namesto vas

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;

Znotraj BeginDoc veja za ponovljivost dodeli FCreationDate := EncodeDate(2026, 1, 1) in identifikator dokumenta zasadi z MD5CalcString('HotPDF-reproducible-seed') namesto z zgoščeno vrednostjo imena datoteke in ure. Ta ena sama dodelitev pokrije oba datuma v slovarju Info in oba datuma v XMP, ker se vsi štirje izrišejo iz istega polja. Ko je datoteka nazadnje zapisana, BuildDocumentIdentifiers za identifikator v repu vpraša ComputeCanonicalDocumentIdentifier: ta izvozi celoten objektni graf v kanoničnem vrstnem redu, števke vsakega najdenega datumskega niza D: postavi na nič, da se časovni žigi ne morejo vrniti skozi zgoščeno vrednost, in vzame MD5 rezultata. Oba elementa /ID prejmeta to vrednost. Isti iz vsebine izpeljani identifikator se uporabi, kadar je naložen dokument šifriran, ne da bi sploh šel skozi BeginDoc, kar je primer ActivateProtection na datoteki, ki ste jo odprli s LoadFromFile

Naključni bajti so najmanj očitna zamenjava. Rutina ključa AES-256 svoj vir naključnosti ovije v lokalni pomožnik, ki ob zastavici pokliče FillChar(P^, Count, $5A) za 32-bajtni ključ za šifriranje datoteke in za vsako 8-bajtno sol, šifriranja nizov in tokov AES-128 ter AES-256 pa preklopijo z AESGenerateRandomIV na AESGenerateStaticIV, ki inicializacijski vektor za mesto I napolni s 14 * (1 + I). Ko so ključ, soli in vektorji vsi fiksni, se /U, /UE, /O, /OE in vsak šifriran tok ob drugem zagonu izidejo enaki. Nazadnje SaveToStream vklopi DeterministicDictionaryOrder vsakič, ko je zastavica ponovljivosti nastavljena, serializator pa nato vsak slovar uredi z vstavljanjem po surovih bajtih njegovih imen ključev, krajši predznak prvi, pri izenačenju pa odloči izvirni indeks. To je isti vrstni red, kot ga uporablja diagnostični pisec, opisan v članku o ročnem urejanju PDF in popravljanju po tem; zastavica ponovljivosti si izposodi le vrstni red in nič drugega iz besedilne postavitve tistega pisca

Kaj ReproducibleOutput pritrdi v HotPDF: datum nastanka postane EncodeDate 2026, 1, 1, identifikator v repu pride iz ComputeCanonicalDocumentIdentifier nad kanoničnim grafom z ničlami na mestu števk D:, ključi in soli AES se napolnijo z bajti $5A, AESGenerateStaticIV napolni vsako mesto, DeterministicDictionaryOrder pa razvrsti vsak slovar
Zamenjave tečejo v istih poteh kode, ki bi sicer segle po uri ali po generatorju naključij, zato ločen prehod čiščenja ni potreben in oba elementa /ID prejmeta isto iz vsebine izpeljano vrednost

Zakaj je fiksni datum vseeno izdal stensko uro?

Popravek v v2.752.2 obstaja zato, ker je bil fiksni datum nastanka prvotno določen v konstruktorju, konstruktor pa ne more poznati lastnosti, ki je klicatelj še ni nastavil. Običajno zaporedje klicev je Create, nato ReproducibleOutput := True in nato BeginDoc. Ob izdelavi je FReproducibleOutput še False, zato je FCreationDate prejel Now in ga obdržal. Identifikator in naključni bajti so bili pravilno pritrjeni, zato sta se datoteki ujemali skoraj povsod in se razlikovali natanko v dveh datumskih nizih in dveh poljih XMP. Premik dodelitve v vejo za ponovljivost znotraj BeginDoc, k zasajenemu identifikatorju, je odločitev postavil na točko, kjer ima lastnost svojo končno vrednost

Regresijski test, ki je to spregledal, je vreden več kot popravek. Dve shranjevanji, ki se zgodita znotraj iste sekunde stenske ure, po naključju zapišeta isti niz D: in primerjava bajtov prestane pri napaki, ki na katerem koli počasnejšem stroju pade. Popravljeni test med obema shranjevanjema spi 1100 ms, da časovni žig PDF zagotovo prestopi mejo sekunde, preizkusi primer za navaden izhod ter za AES-128 in AES-256 z resničnimi gesli na obeh šifriranih različicah in primerja oba medpomnilnika s CompareMem, ob spodletelosti pa sporoči prvi različni odmik, tako da primerjava pokaže na konkreten objekt in ne na celo datoteko. Primerjava bajtov dokaže determinizem in nič drugega, zato obdržite ločeno trditev, ki šifriran izhod znova naloži z uporabniškim geslom in prebere število strani; sprememba, ki datoteko naredi stabilno in neberljivo hkrati, ne sme uiti na podlagi zelene primerjave

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 telesu testa
A := SaveOnce(PathA);
TThread.Sleep(1100);          // vsili drugo sekundo časovnega žiga 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');

Je ponovljiv šifriran PDF še varen?

Ne. Dokument, šifriran pod ReproducibleOutput, ni zaščiten v nobenem smiselnem pomenu in zastavica mora biti izklopljena za vse, kar zapusti testno mapo. Ključ AES-256 za šifriranje datoteke je dvaintrideset bajtov $5A, soli so osem bajtov $5A, inicializacijski vektorji pa sledijo objavljenemu aritmetičnemu vzorcu. Geslo še vedno varuje ovoja /UE in /OE, oviti ključ pa je konstanta, zato lahko vsakdo, ki konstanto pozna, dešifrira vsak tok vsebine povsem brez gesla. Fiksne soli odpravijo tudi edinstvenost na ravni dokumenta, na katero se ISO 32000-2 §7.6.4.4.7 zanaša, da enaka gesla v različnih datotekah ne dajo enakih nizov /U. Preberite članek o nastavitvi AES-256, kaj obljubljajo lastnosti šifriranja, ko je vir naključnosti nepoškodovan; pod zastavico ponovljivosti so te obljube začasno ukinjene

Kompromis pri identifikatorju je bolj subtilen. ISO 32000-1 §14.4 želi, da se drugi element /ID spremeni ob vsaki spremembi, da lahko orodja ločijo posodobljeno datoteko od njene predhodnice, ponovljivo shranjevanje pa v obe mesti zapiše isto vrednost. Ker je ta vrednost zgoščena vrednost kanoničnega objektnega grafa, dva dokumenta z različno vsebino vseeno dobita različna identifikatorja, kar je bolje od konstante. A seme, ki ga BeginDoc uporablja za izpeljavo ključa, je isti niz za vsak dokument na vsakem stroju, in bralec, ki po /ID ločuje datoteke, na primer predpomnilnik opomb ali spremljevalna datoteka podatkov obrazca, bo zamešal vsako ponovljivo datoteko, ki se slučajno zgošča enako

Česa zastavica ne pokriva?

ReproducibleOutput odstrani entropijo, ki jo pisec vnese sam; ne more odstraniti entropije, ki vstopa skozi okolje ali skozi poti kode, ki jih ne nadzoruje, tri od teh pa je lahko sprožiti

  • Pripona časovnega pasu. _DateTimeToPdfDate priloži krajevni odmik od UTC, zato sta D:20260101000000+08'00' na enem strežniku gradnje in D:20260101000000-05'00' na drugem različna bajta za isti fiksni datum. Ponovljivost velja med zagoni na enem stroju ali med stroji, ki si delijo časovni pas; če vaše zlate datoteke potujejo, pripnite pas strežnika
  • Inkrementalne posodobitve. SaveIncrementalUpdate svoj identifikator spremembe izračuna iz ciljne poti, GetTickCount in trenutnega časa, brez veje za ponovljivost, ker je inkrementalni odsek po definiciji nova sprememba. Primerjajte polne prepise in ne priloženih delt
  • Bližnjica prepustitve. SaveLoadedDocument običajno nespremenjeno, nešifrirano izvorno datoteko prekopira bajt za bajt, namesto da bi jo znova serializiral. Zastavica ponovljivosti to bližnjico izklopi in vsili poln prepis, da veljajo pravila o vrstnem redu in identifikatorju, kar pomeni, da je ponovljivo shranjevanje naložene datoteke počasnejše od privzetega in nikoli ni kopija vhoda. Primerjajte ga s prejšnjim ponovljivim shranjevanjem in nikoli z izvirnikom
Kje se ponovljiva shranjevanja HotPDF ustavijo: _DateTimeToPdfDate še vedno priloži krajevni odmik od UTC, zato se zlate datoteke med časovnimi pasovi razlikujejo, SaveIncrementalUpdate nima veje za ponovljivost, ker je delta nova sprememba, bližnjica prepustitve pa je izklopljena, zato se naložena datoteka vedno prepiše v celoti
Ponovljivost velja med zagoni na enem stroju ali med stroji, ki si delijo pas, ponovljivo shranjevanje pa velja primerjati s prejšnjim ponovljivim shranjevanjem in nikoli z izvirnim vhodom

Še en nauk iz iste izdaje, o tem, kaj uspešno preverjanje dokaže in česa ne. Fiksna datoteka testa PDF/X-6 je poklicala CharProcs.DeleteValue('A'), kar je sprostilo neposredno držan tok glifa, nato pa znova vstavila isti kazalec, in ločeno predala en neposredni objekt ExtGState tako slovarju virov kot vzorcu. Preverjevalnik skladnosti je pri tem sproščanju med uporabo in dvojnem lastništvu vmes prestal, ker je bral, kar koli je sproščeni pomnilnik slučajno vseboval. Ko strukturno preverjanje utripa, poglejte lastništvo testnega vhoda, preden pogledate preverjevalnik. Ponovljiv izhod to disciplino poceni: ko sta dve shranjevanji bajtno enaki, je edini preostali vir utripanja objektni graf sam in strukturna primerjava od kataloga navzdol ga bo našla

Lastnosti ReproducibleOutput, DeterministicDictionaryOrder in šifriranje, opisane tukaj, se dobavljajo v standardnem HotPDF Delphi Component za Delphi in C++Builder, ista zastavica pa poganja tudi lastni regresijski korpus knjižnice, zato je vedenje, ki ga dobite v testnem nizu, vedenje, s katerim je komponenta testirana