Műszaki cikk

Reprodukálható PDF-kimenet Delphiben: bájtazonos mentések

A HotPDF Delphi Component bájtazonos PDF-kimenetet állít elő a mentések között, amikor a ReproducibleOutput tulajdonság True: az Info /CreationDate és /ModDate mezőjét egy rögzített dátumra tűzi ki, a valós idejű dokumentumazonosítót egy seedelt vagy tartalomból származtatott hashel cseréli, konstansokat tesz minden véletlen bájt helyére, amit az AES titkosítási útvonalak egyébként kihúznának, és rendez minden szótárat, amit sorosít. A flag regressziós sorozatokhoz és build-artefaktumok összehasonlításához létezik, nem produkciós dokumentumokhoz, és pont ennek a határvonalnak az okai az érdekesek. A forgatókönyv, ami a funkciót vezérli, egy golden-file teszt. Renderelsz egy számlát, commitolod a PDF-et, és állítod, hogy a holnapi build ugyanazokat a bájtokat állítja elő. Soha nem teszi. A fájl minden megjelenítőben szépen megnyílik, a szöveg azonos, az oldalfa azonos, a diff viszont mégis négy-öt helyen világít. Bárki, aki megpróbálta egy PDF-generátort bájt szintű regressziós teszt alá tenni, belefutott ebbe a falba, és a megoldás nem az, hogy „leszedjük az időbélyegeket”, hanem az, hogy pontosan számba vesszük, hol konzultál az író mást, mint magát a dokumentumot

Miért tér el ugyanannak a PDF-nek a két mentése?

Ugyanannak a dokumentumnak a két mentése azért tér el, mert egy PDF-író, a HotPDF is beleértve, négy entrópiaforrást konzultál, amiknek semmi közük az oldaltartalomhoz: a valós óra, a dokumentumazonosító, a kriptográfiai véletlenszám-generátor és a szótárbejegyzések memóriabeli sorrendje. Ezek mindegyike legitim önmagában. Az ISO 32000-1 szeretné, ha ott lennének. Egyszerűen a fájlt abból csinálják függvényt, hogy mikor és hol írták, nem abból, hogy mi van benne

  • Az óra. Az Info-szótár a /CreationDate és /ModDate mezőt (ISO 32000-1 §14.3.3, 317. táblázat) D:YYYYMMDDHHmmSS stringekként hordozza időzóna-utótaggal (§7.9.4), az XMP-csomag pedig ugyanazt a pillanatot ismétli meg xmp:CreateDate és xmp:ModifyDate formában. A HotPDF mindkettőt az FCreationDate-ből bélyegzi, amit a konstruktor Now-ra inicializál, így a két mentés abban a másodpercben tér el, amikor írták
  • Az azonosító. A trailer /ID tömbje (ISO 32000-1 §14.4) egy állandó azonosítót és egy módosítási azonosítót tart. A HotPDF alapértelmezett receptje az első elemhez a fájlnevet és az aktuális időt hasheli le ezredmásodpercig, a másodikhoz pedig azt plusz a GetTickCount-ot. Két azonosító, két friss érték minden futásban
  • A véletlen bájtok. A szabványos biztonság az azonosítótól és valódi véletlenszerűségtől függ. AES-256-nál a fájltitkosítási kulcs, az érvényesítési és kulcssók, valamint minden CBC inicializációs vektor a rendszer véletlenforrásából származik (az ISO 32000-2 §7.6.4.4.7 véletlen sókat követel). Mivel az /U, az /UE, az /O és az /OE mind ezekből a bájtokból számolódik, egy titkosított dokumentum teljes egészében megváltozik akkor is, amikor a nyílt szöveg nem. A régebbi algoritmusok az első /ID elemet behajtják a kulcsba (ISO 32000-1 §7.6.3.3, §7.6.3.4), így egy friss azonosító önmagában elég a fájl újrakulcsolásához
  • A sorrend. Egy PDF-szótár rendezetlen leképezés, és az az író, ami a memóriabeli listáját bejárja, a beszúrás sorrendjében bocsátja ki a kulcsokat. Bármely kódútvonal, ami más sorrendben épít fel egy erőforrás-szótárt, vagy egy betöltött dokumentum, amit más elrendezésből parse-oltak, érvényes, de szövegileg eltérő fájlt ad
A négy entrópiaforrás, amik eltérővé teszik egy dokumentum két HotPDF-mentését: az FCreationDate a Now-ból bélyegződik és a D: dátumokat meg az XMP-csomagot táplálja, a trailer /ID a fájlnevet, az órát és a GetTickCount-ot hashelik, az AES a rendszer véletlenforrásából húz kulcsanyagot, a szótárak pedig a memóriabeli beszúrási sorrendben sorosulnak
Minden forrás legitim önmagában, és az ISO 32000-1 szeretné, ha ott lennének, együtt viszont a fájlt abból csinálják függvényt, hogy mikor és hol írták, nem abból, hogy mi van benne

Mit tűz ki a ReproducibleOutput?

A ReproducibleOutput := True beállítása a BeginDoc vagy a SaveLoadedDocument előtt mind a négy forrást rögzített értékre cseréli, méghozzá azokban a kódútvonalakban, amik egyébként az óráért vagy a véletlengenerátorért nyúlnának, így nincs szükség külön takarító menetre. Vedd észre, mi hiányzik a fenti listából: a tartalom. A betűkészletek, az oldalstreamek, a képadat és a cross-reference tábla már determinisztikusak ugyanarra a bemenetre; a zaj teljes egészében a metaadatban és a biztonsági rétegben él, ezért tudja egyetlen célzott tulajdonság eltávolítani. A tulajdonság alapértéke False, és a könyvtárban semmi nem kapcsolja be helyetted

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

A BeginDoc-en belül a reprodukálható ág az FCreationDate := EncodeDate(2026, 1, 1) értéket adja, és a dokumentumazonosítót a MD5CalcString('HotPDF-reproducible-seed') értékkel seedeli a fájlnév-plusz-óra digest helyett. Ez az egyetlen értékadás lefedi mindkét Info-dátumot és mindkét XMP-dátumot, mert mind a négy ugyanabból a mezőből renderelődik. Amikor a fájl végül kiíródik, a BuildDocumentIdentifiers a ComputeCanonicalDocumentIdentifier-től kéri a trailer-azonosítót: az exportálja a teljes objektumgráfot kanonikus sorrendben, nullázza minden általa talált D: dátumstring számjegyeit, hogy az időbélyegek ne szivárogjanak vissza a hashen keresztül, és az eredmény MD5-jét veszi. A /ID mindkét eleme ezt az értéket kapja. Ugyanezt a tartalomból származtatott azonosítót használja a rendszer, amikor egy betöltött dokumentumot titkosítunk anélkül, hogy valaha átmenne a BeginDoc-on, ami a LoadFromFile-fal megnyitott fájlon hívott ActivateProtection esete

A véletlen bájtok a legkevésbé nyilvánvaló helyettesítés. Az AES-256 kulcsrutin a véletlenforrását egy lokális helperbe csomagolja, ami a flag alatt FillChar(P^, Count, $5A)-t hív a 32 bájtos fájltitkosítási kulcsra és minden 8 bájtos sóra, az AES-128 és AES-256 string- és stream-titkosítók pedig az AESGenerateRandomIV-ről az AESGenerateStaticIV-re váltanak, ami az inicializációs vektort 14 * (1 + I)-vel tölti fel az I helyen. Ha a kulcs, a sók és a vektorok mind rögzítettek, az /U, az /UE, az /O, az /OE és minden titkosított stream ugyanazzal az eredménnyel jön ki a második futásban. Végül a SaveToStream bekapcsolja a DeterministicDictionaryOrder-t, valahányszor a reprodukálható flag be van állítva, a szerializáló pedig minden szótárat beszúrásos rendezéssel rendez a kulcsnevek nyers bájtjai szerint, rövidebb prefix először, az eredeti indexszel döntetlen esetén. Ugyanez a sorrend, amit a diagnosztikai író használ, amit a PDF kézi szerkesztéséről és utána való javításáról szóló cikk ír le; a reprodukálható flag csak a rendezést kölcsönzi, nem az író többi nyílt szövegű elrendezését

Mit tűz ki a ReproducibleOutput a HotPDF-ben: a létrehozási dátum EncodeDate 2026, 1, 1 lesz, a trailer-azonosító a ComputeCanonicalDocumentIdentifier-ből jön a kanonikus gráf fölött nullázott D: számjegyekkel, az AES-kulcsok és sók $5A bájtokkal töltődnek fel és az AESGenerateStaticIV tölti ki az egyes helyeket, a DeterministicDictionaryOrder pedig minden szótárat rendez
A helyettesítések ugyanazokban a kódútvonalakban futnak, amik egyébként az óráért vagy a véletlengenerátorért nyúlnának, így nincs szükség külön takarító menetre, és a /ID mindkét eleme ugyanazt a tartalomból származtatott értéket kapja

Miért szivárogtatta ki a rögzített dátum is a valós órát?

A v2.752.2 javítása azért létezik, mert a rögzített létrehozási dátum eredetileg a konstruktorban dőlt el, a konstruktor pedig nem tudhat egy olyan tulajdonságról, amit a hívó még nem állított be. A normál hívássorrend: Create, majd ReproducibleOutput := True, majd BeginDoc. Építéskor az FReproducibleOutput még False, így az FCreationDate megkapta a Now-t, és meg is tartotta. Az azonosító és a véletlen bájtok helyesen voltak rögzítve, így a két fájl majdnem mindenhol megegyezett, és pontosan két dátumstringben meg két XMP-mezőben tért el. Az értékadást a BeginDoc reprodukálható ágába, a seedelt azonosító mellé tenni a döntést oda tette, ahol a tulajdonság már a végleges értékét hordozza

A regressziós teszt, ami ezt elvétette, többet ér, mint maga a javítás. Két mentés, ami ugyanabban a valós másodpercben fut, véletlenül ugyanazt a D: stringet írja, és a bájt-összehasonlítás átmegy egy olyan hibára, ami bármely lassabb gépen elbukik. A javított teszt 1100 ms-ot alszik a két mentés között, hogy a PDF-időbélyeg garantáltan átlépjen egy másodperc-határt, lefuttatja az esetet sima, AES-128 és AES-256 kimenetre valódi jelszavakkal a két titkosított változaton, és a két buffert a CompareMem-mel hasonlítja össze, hiba esetén az első eltérő eltolást jelentve, hogy a diff egy konkrét objektumra mutasson egész fájl helyett. Egy bájt-összehasonlítás a determinizmust bizonyítja, és semmi mást, ezért tartson külön állítást, ami újratölti a titkosított kimenetet a felhasználói jelszóval, és kiolvas egy oldalszámot; egy változás, ami egyszerre teszi a fájlt stabillá és olvashatatlanná, nem csúszhat át egy zöld diff erejére támaszkodva

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;

// a teszt törzsében
A := SaveOnce(PathA);
TThread.Sleep(1100);          // kényszeríts ki egy másik PDF-időbélyeg-másodpercet
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');

Biztonságos-e még egy reprodukálható titkosított PDF?

Nem. Egy ReproducibleOutput alatt titkosított dokumentum semmilyen értelmes értelemben nem védett, és a flagnek ki kell lennie kapcsolva mindenhez, ami elhagyja a tesztkönyvtárat. Az AES-256 fájltitkosítási kulcs harminckét bájt $5A, a sók nyolc bájt $5A, az inicializációs vektorok pedig egy publikált aritmetikai mintát követnek. A jelszó továbbra is kaput tart az /UE és /OE burkolók előtt, a becsomagolt kulcs viszont konstans, így bárki, aki ismeri a konstanst, minden tartalom-streamet vissza tud fejteni jelszó nélkül. A rögzített sók ráadásul elveszik azt a dokumentumonkénti egyediséget, amire az ISO 32000-2 §7.6.4.4.7 támaszkodik, hogy az azonos jelszavak ne adjanak azonos /U stringeket a fájlok között. Olvasd el a AES-256 beállításról szóló cikket arról, mit ígérnek a titkosítási tulajdonságok, amikor a véletlenforrás ép; a reprodukálható flag alatt azok az ígéretek fel vannak függesztve

Az azonosító kompromisszuma finomabb. Az ISO 32000-1 §14.4 azt szánja a második /ID elemnek, hogy minden módosításkor megváltozzon, hogy az eszközök meg tudják különböztetni egy frissített fájlt az ősétől, egy reprodukálható mentés viszont ugyanazt az értéket írja mindkét helyre. Mivel az az érték a kanonikus objektumgráf hash-e, két különböző tartalmú dokumentum így is különböző azonosítót kap, ami jobb egy konstansnál. A seed viszont, amit a BeginDoc a kulcslevezetéshez használ, ugyanaz a string minden dokumentumhoz minden gépen, és egy olvasó, ami a /ID-re kulcsol a fájlok megkülönböztetéséhez, például egy annotáció-cache vagy egy űrlapadat-mellékfájl, minden reprodukálható fájlt összemos, amik ugyanarra hashelnek

Mit nem fed le a flag?

A ReproducibleOutput eltávolítja az entrópiát, amit az író maga vezet be; nem tudja eltávolítani azt az entrópiát, ami a környezeten vagy olyan kódútvonalakon keresztül érkezik, amiket nem kontrollál, és ezek közül három könnyen megbotlik

  • Az időzóna-utótag. A _DateTimeToPdfDate hozzáfűzi a helyi UTC-eltolást, így a D:20260101000000+08'00' az egyik build-ügynökön és a D:20260101000000-05'00' a másikon ugyanannak a rögzített dátumnak két különböző bájt. A reprodukálhatóság egy gépen, vagy azonos időzónát osztó gépek között érvényes; tűzd ki az ügynök zónáját, ha a golden-fájljaid utaznak
  • Inkrementális frissítések. A SaveIncrementalUpdate a módosítási azonosítóját a célútvonalból, a GetTickCount-ból és az aktuális időből számolja, reprodukálható ág nélkül, mert egy inkrementális szakasz definíció szerint új módosítás. Teljes újraírásokat hasonlíts össze, ne hozzáfűzött deltákat
  • A passthrough-gyorsút. A SaveLoadedDocument normál esetben bájtról bájtra másol egy módosítatlan, titkosítatlan forrásfájlt újrasorosítás helyett. A reprodukálható flag letiltja azt a gyorsutat, és teljes újraírást kényszerít ki, hogy a rendezési és azonosítószabályok érvényesüljenek, ami azt jelenti, hogy egy betöltött fájl reprodukálható mentése lassabb az alapértelmezettnél, és soha nem a bemenet másolata. Egy korábbi reprodukálható mentéshez diffeld, soha ne az eredetihez
Hol állnak meg a HotPDF reprodukálható mentései: a _DateTimeToPdfDate továbbra is hozzáfűzi a helyi UTC-eltolást, így a golden-fájlok eltérnek az időzónák között, a SaveIncrementalUpdate-nek nincs reprodukálható ága, mert egy delta új módosítás, a passthrough-gyorsút pedig le van tiltva, így egy betöltött fájl mindig teljesen újraíródik
A reprodukálhatóság egy gépen, vagy azonos zónát osztó gépek között érvényes, egy reprodukálható mentést pedig egy korábbi reprodukálható mentéshez érdemes diffelni, soha nem az eredeti bemenethez

Még egy tanulság ugyanabból a kiadásból, arról, mit bizonyít és mit nem egy átmenő ellenőrzés. Egy PDF/X-6 teszt-fixture a CharProcs.DeleteValue('A')-t hívta, ami felszabadított egy közvetlenül tartott glif-streamet, majd újra beszúrta ugyanazt a pointert, és külön odaadta ugyanazt a direkt ExtGState objektumot egy erőforrás-szótárnak meg egy patternnek. A megfelelőség-validátor időnként átment azon a use-after-free-en és kettős birtokláson, mert azt olvasta, amit a felszabadított memória épp tartalmazott. Amikor egy strukturális ellenőrzés villog, a tesztbemenet birtoklását nézd meg, mielőtt a validátort néznéd. A reprodukálható kimenet olcsóbbá teszi ezt a fegyelmet: amint két mentés bájtazonos, a villogás egyetlen maradék forrása maga az objektumgráf, és a katalógustól lefelé haladó strukturális diff meg is fogja találni

Az itt leírt ReproducibleOutput, DeterministicDictionaryOrder és titkosítási tulajdonságok a standard HotPDF Delphi Component részét képezik Delphihez és C++Builderhez, és ugyanez a flag hajtja a könyvtár saját regressziós korpuszát, így az a viselkedés, amit egy tesztsorozatban kapsz, az a viselkedés, amivel a komponenst tesztelik