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/ModDatemezőt (ISO 32000-1 §14.3.3, 317. táblázat)D:YYYYMMDDHHmmSSstringekként hordozza időzóna-utótaggal (§7.9.4), az XMP-csomag pedig ugyanazt a pillanatot ismétli megxmp:CreateDateésxmp:ModifyDateformában. A HotPDF mindkettőt azFCreationDate-ből bélyegzi, amit a konstruktorNow-ra inicializál, így a két mentés abban a másodpercben tér el, amikor írták - Az azonosító. A trailer
/IDtö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 aGetTickCount-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/OEmind 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ő/IDelemet 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
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
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
_DateTimeToPdfDatehozzáfűzi a helyi UTC-eltolást, így aD:20260101000000+08'00'az egyik build-ügynökön és aD: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
SaveIncrementalUpdatea módosítási azonosítóját a célútvonalból, aGetTickCount-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
SaveLoadedDocumentnormá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
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