Techninis straipsnis

Atkuriama PDF išvestis Delphi: identiški baitai

HotPDF Delphi Component duoda baitas į baitą identišką PDF išvestį tarp išsaugojimų, kai ReproducibleOutput savybė yra True: ji fiksuoja Info /CreationDate ir /ModDate ties fiksuota data, pakeičia sieninio laikrodžio dokumento identifikatorių sėkla pagrįstu arba iš turinio išvestu maišu, pakeičia konstantomis kiekvieną atsitiktinį baitą, kurį AES šifravimo keliai kitaip trauktų, ir surikiuoja kiekvieną serializuojamą žodyną. Vėliavėlė egzistuoja regresijos rinkiniams ir kūrimo artefaktų palyginimui, o ne produkciniams dokumentams, ir tos ribos priežastys yra įdomiausia dalis. Scenarijus, kuris ir varo šią funkciją, yra auksinio failo testas. Sugeneruojate sąskaitą faktūrą, įtraukiate PDF į commit, ir tvirtinate, kad rytojaus rinkinys pagamins tuos pačius baitus. Taip niekada nebūna. Failas puikiai atsidaro kiekvienoje peržiūros programoje, tekstas identiškas, puslapių medis identiškas, o skirtumai vis tiek užsidega keturiose ar penkiose vietose. Kiekvienas, bandęs PDF generatorių pastatyti po baitų lygio regresijos testu, yra atsimušęs į šią sieną, o pataisa yra ne „išmesk laiko žymas“, o tikslus apskaitos suvedimas kiekvienoje vietoje, kur rašytojas kreipiasi į ką nors kita, o ne į patį dokumentą

Kodėl du to paties PDF išsaugojimai skiriasi?

Du to paties dokumento išsaugojimai skiriasi todėl, kad PDF rašytojas, įskaitant HotPDF, kreipiasi į keturis entropijos šaltinius, kurie neturi nieko bendra su puslapių turiniu: sieninį laikrodį, dokumento identifikatorių, kriptografinį atsitiktinių skaičių generatorių ir žodyno įrašų tvarką atmintyje. Kiekvienas jų atskirai yra teisėtas. ISO 32000-1 jų ten nori. Jie tiesiog padaro failą funkcija nuo to, kada ir kur jis buvo įrašytas, o ne nuo to, ką jis talpina

  • Laikrodis. Info žodynas nešasi /CreationDate ir /ModDate (ISO 32000-1 §14.3.3, 317 lentelė) kaip D:YYYYMMDDHHmmSS eilutes su laiko juostos priesaga (§7.9.4), o XMP paketas kartoja tą patį momentą kaip xmp:CreateDate ir xmp:ModifyDate. HotPDF abu įspaudžia iš FCreationDate, kurią konstruktorius inicializuoja į Now, tad du išsaugojimai skiriasi ta sekunde, kurią buvo įrašyti
  • Identifikatorius. Trailer /ID masyvas (ISO 32000-1 §14.4) laiko nuolatinį identifikatorių ir modifikavimo identifikatorių. HotPDF numatytasis receptas maišuoja failo vardą kartu su dabartiniu laiku iki milisekundžių pirmajam elementui, o tą rezultatą plius GetTickCount – antrajam. Du identifikatoriai, dvi šviežios reikšmės kiekvieno paleidimo metu
  • Atsitiktiniai baitai. Standartinis saugumas priklauso nuo identifikatoriaus ir nuo tikro atsitiktinumo. AES-256 failo šifravimo raktas, validavimo ir rakto druskos bei kiekvienas CBC inicializacijos vektorius traukiami iš sistemos atsitiktinių skaičių šaltinio (ISO 32000-2 §7.6.4.4.7 reikalauja atsitiktinių druskų). Kadangi /U, /UE, /O ir /OE visi apskaičiuojami iš tų baitų, užšifruotas dokumentas pasikeičia visas, net kai atvirasis tekstas nesikeičia. Senesni algoritmai pirmą /ID elementą įlanksto į raktą (ISO 32000-1 §7.6.3.3, §7.6.3.4), tad vien šviežio identifikatoriaus pakanka failui perrakuoti
  • Tvarka. PDF žodynas yra netvarkingas atvaizdis, o rašytojas, einantis per savo sąrašą atmintyje, išduoda raktus įterpimo tvarka. Bet kuris kodo kelias, sukuriantis resursų žodyną kita seka, arba įkeltas dokumentas, kuris buvo išanalizuotas iš kito išdėstymo, duoda teisėtą, bet tekstiniu požiūriu kitokį failą
Keturi entropijos šaltiniai, dėl kurių du to paties dokumento HotPDF išsaugojimai skiriasi: FCreationDate, įspausta iš Now, maitina D: datas ir XMP paketą, trailer /ID maišuoja failo vardą, laikrodį ir GetTickCount, AES traukia rakto medžiagą iš sistemos atsitiktinių skaičių šaltinio, o žodynai serializuojami įterpimo tvarka atmintyje
Kiekvienas šaltinis atskirai yra teisėtas ir ISO 32000-1 jų ten nori, bet kartu jie paverčia failą funkcija nuo to, kada ir kur jis buvo įrašytas, o ne nuo to, ką jis talpina

Ką ReproducibleOutput užfiksuoja?

Nustačius ReproducibleOutput := True prieš BeginDoc arba prieš SaveLoadedDocument, kiekvienas iš keturių šaltinių pakeičiamas fiksuota reikšme, ir tai daroma tuose pačiuose kodo keliuose, kurie kitaip siektų laikrodžio ar atsitiktinių skaičių generatoriaus, tad atskiro valymo praėjimo nereikia. Atkreipkite dėmesį, ko aukščiau esančiame sąraše nėra: turinio. Šriftai, puslapių srautai, paveikslėlių duomenys ir kryžminių nuorodų lentelė tai pačiai įvesčiai jau yra deterministiniai; triukšmas gyvena vien metaduomenyse ir saugumo sluoksnyje, ir būtent todėl viena tikslinė savybė gali jį pašalinti. Savybės numatytoji reikšmė yra False, ir niekas bibliotekoje jos jums neįjungia

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

BeginDoc viduje atkuriamoji šaka priskiria FCreationDate := EncodeDate(2026, 1, 1) ir duoda sėklą dokumento identifikatoriui su MD5CalcString('HotPDF-reproducible-seed') vietoje failo vardo ir laikrodžio santraukos. Tas vienas priskyrimas uždengia abi Info datas ir abi XMP datas, nes visos keturios atvaizduojamos iš to paties lauko. Kai failas galiausiai įrašomas, BuildDocumentIdentifiers paprašo ComputeCanonicalDocumentIdentifier trailer identifikatoriaus: jis eksportuoja visą objektų grafą kanonine tvarka, sunulina bet kurios rastos D: datos eilutės skaitmenis, kad laiko žymos negalėtų nutekėti atgal per maišą, ir paima rezultato MD5. Abu /ID elementai gauna tą reikšmę. Tas pats iš turinio išvestas identifikatorius naudojamas, kai įkeltas dokumentas užšifruojamas net nepraėjęs per BeginDoc – būtent toks yra ActivateProtection atvejis failui, kurį atidarėte su LoadFromFile

Atsitiktiniai baitai yra mažiausiai akivaizdus pakaitalas. AES-256 rakto rutina savo atsitiktinių skaičių šaltinį apgaubia vietiniu pagalbininku, kuris su vėliavėle iškviečia FillChar(P^, Count, $5A) 32 baitų failo šifravimo raktui ir kiekvienai 8 baitų druskai, o AES-128 ir AES-256 eilučių bei srautų šifruotojai persijungia nuo AESGenerateRandomIV prie AESGenerateStaticIV, kuri inicializacijos vektorių užpildo 14 * (1 + I) reikšme vietai I. Kai raktas, druskos ir vektoriai visi fiksuoti, /U, /UE, /O, /OE ir kiekvienas užšifruotas srautas antro paleidimo metu išeina identiški. Galiausiai SaveToStream įjungia DeterministicDictionaryOrder, kai tik nustatyta atkuriamumo vėliavėlė, o serializatorius tada kiekvieną žodyną surikiuoja įterpimo rūšiavimu pagal savo raktų vardų neapdorotus baitus, trumpesnis priešdėlis pirmas, su pradiniu indeksu kaip lygiųjų laužytoju. Tai ta pati tvarka, kurią naudoja diagnostinis rašytojas, aprašyta straipsnyje apie PDF redagavimą ranka ir jo taisymą po to; atkuriamumo vėliavėlė skolinasi tik tvarką, o ne likusį to rašytojo atviro teksto išdėstymą

Ką ReproducibleOutput užfiksuoja HotPDF: sukūrimo data tampa EncodeDate 2026, 1, 1, trailer identifikatorius ateina iš ComputeCanonicalDocumentIdentifier per kanoninį grafą su sunulintais D: skaitmenimis, AES raktai ir druskos užpildomi $5A baitais, o AESGenerateStaticIV užpildo kiekvieną vietą, ir DeterministicDictionaryOrder surikiuoja kiekvieną žodyną
Pakaitalai veikia tuose pačiuose kodo keliuose, kurie kitaip siektų laikrodžio ar atsitiktinių skaičių generatoriaus, tad atskiro valymo praėjimo nereikia, ir abu /ID elementai gauna tą pačią iš turinio išvestą reikšmę

Kodėl fiksuota data vis tiek nutekino sieninį laikrodį?

v2.752.2 pataisa egzistuoja todėl, kad fiksuota sukūrimo data iš pradžių buvo nusprendžiama konstruktoriuje, o konstruktorius negali žinoti savybės, kurios kviečiantysis dar nenustatė. Įprasta kvietimų seka yra Create, tada ReproducibleOutput := True, tada BeginDoc. Konstravimo metu FReproducibleOutput vis dar yra False, tad FCreationDate gaudavo Now ir ją išlaikydavo. Identifikatorius ir atsitiktiniai baitai buvo užfiksuoti teisingai, tad abu failai sutarė beveik visur ir nesutarė lygiai dviejose datos eilutėse ir dviejuose XMP laukuose. Priskyrimo perkėlimas į atkuriamąją BeginDoc šaką, greta sėkla pagrįsto identifikatoriaus, pastatė sprendimą ten, kur savybė jau turi galutinę reikšmę

Regresijos testas, kuris to nepastebėjo, vertas daugiau už pačią pataisą. Du išsaugojimai, įvykę per tą pačią sieninio laikrodžio sekundę, atsitiktinai įrašo tą pačią D: eilutę, ir baitų palyginimas praeina dėl defekto, kuris krinta bet kurioje lėtesnėje mašinoje. Pataisytas testas tarp dviejų išsaugojimų miega 1100 ms, kad PDF laiko žyma būtinai peržengtų sekundės ribą, paleidžia atvejį paprastai, AES-128 ir AES-256 išvesčiai su tikrais slaptažodžiais abiem užšifruotiems variantams ir palygina du buferius su CompareMem, pranešdamas pirmą besiskiriantį poslinkį nesėkmės atveju, kad skirtumas rodytų į konkretų objektą, o ne į visą failą. Baitų palyginimas įrodo determinizmą ir nieko daugiau, tad laikykite atskirą tvirtinimą, kuris iš naujo įkelia užšifruotą išvestį su naudotojo slaptažodžiu ir perskaito puslapių skaičių; pakeitimas, kuris failą padaro stabilų ir neperskaitomą vienu metu, neturi praslysti žalio skirtumo dėka

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;

// testo kūne
A := SaveOnce(PathA);
TThread.Sleep(1100);          // priverstinai kita PDF laiko žymos sekundė
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');

Ar atkuriamas užšifruotas PDF vis dar saugus?

Ne. Dokumentas, užšifruotas su ReproducibleOutput, nėra apsaugotas jokia prasminga prasme, ir vėliavėlė turi būti išjungta viskam, kas palieka testų katalogą. AES-256 failo šifravimo raktas yra trisdešimt du $5A baitai, druskos yra aštuoni $5A baitai, o inicializacijos vektoriai seka paskelbtą aritmetinį dėsningumą. Slaptažodis vis dar saugo /UE ir /OE apvalkalus, bet apvyniotas raktas yra konstanta, tad kas žino tą konstantą, gali iššifruoti kiekvieną turinio srautą visai be slaptažodžio. Fiksuotos druskos taip pat panaikina kiekvieno dokumento unikalumą, kuriuo remiasi ISO 32000-2 §7.6.4.4.7, kad identiški slaptažodžiai neduotų identiškų /U eilučių skirtinguose failuose. Apie tai, ką šifravimo savybės žada, kai atsitiktinių skaičių šaltinis sveikas, skaitykite AES-256 nustatymo straipsnyje; su atkuriamumo vėliavėle tie pažadai yra sustabdyti

Kompromisas dėl identifikatoriaus yra subtilesnis. ISO 32000-1 §14.4 nori, kad antrasis /ID elementas keistųsi su kiekvienu modifikavimu, kad įrankiai atskirtų atnaujintą failą nuo jo protėvio, o atkuriamas išsaugojimas į abi vietas įrašo tą pačią reikšmę. Kadangi ta reikšmė yra kanoninio objektų grafo maiša, du skirtingo turinio dokumentai vis tiek gauna skirtingus identifikatorius, ir tai geriau už konstantą. Bet sėkla, kurią BeginDoc naudoja raktui išvesti, yra ta pati eilutė kiekvienam dokumentui kiekvienoje mašinoje, o skaitytuvas, kuris pagal /ID skiria failus – pavyzdžiui anotacijų podėlis ar formos duomenų šalutinis failas – sulies kiekvieną atkuriamą failą, kurio maiša sutampa

Ko vėliavėlė neuždengia?

ReproducibleOutput pašalina entropiją, kurią rašytojas įneša pats; jis negali pašalinti entropijos, kuri patenka per aplinką ar per jo nekontroliuojamus kodo kelius, ir į tris iš jų lengva užkliūti

  • Laiko juostos priesaga. _DateTimeToPdfDate prideda vietinį UTC poslinkį, tad D:20260101000000+08'00' viename kūrimo agente ir D:20260101000000-05'00' kitame yra skirtingi baitai tai pačiai fiksuotai datai. Atkuriamumas galioja tarp paleidimų vienoje mašinoje arba tarp mašinų, besidalijančių laiko juosta; užfiksuokite agento juostą, jei jūsų auksiniai failai keliauja
  • Inkrementiniai atnaujinimai. SaveIncrementalUpdate savo modifikavimo identifikatorių apskaičiuoja iš tikslinio kelio, GetTickCount ir dabartinio laiko be jokios atkuriamosios šakos, nes inkrementinė sekcija iš apibrėžimo yra naujas modifikavimas. Palyginkite pilnus perrašymus, o ne pridėtus skirtumus
  • Praėjimo trumpinys. SaveLoadedDocument paprastai nepakeistą, neužšifruotą šaltinio failą nukopijuoja baitas į baitą, o ne jį serializuoja iš naujo. Atkuriamumo vėliavėlė tą trumpinį išjungia ir priverčia pilnai perrašyti, kad galiotų tvarkos ir identifikatoriaus taisyklės, o tai reiškia, kad atkuriamas įkelto failo išsaugojimas yra lėtesnis už numatytąjį ir niekada nėra įvesties kopija. Palyginkite jį su ankstesniu atkuriamu išsaugojimu, o niekada su originalu
Kur HotPDF atkuriami išsaugojimai sustoja: _DateTimeToPdfDate vis tiek prideda vietinį UTC poslinkį, tad auksiniai failai skiriasi tarp laiko juostų, SaveIncrementalUpdate neturi atkuriamosios šakos, nes skirtumas yra naujas modifikavimas, o praėjimo trumpinys išjungtas, tad įkeltas failas visada pilnai perrašomas
Atkuriamumas galioja tarp paleidimų vienoje mašinoje arba tarp mašinų, besidalijančių juosta, o atkuriamą išsaugojimą reikia lyginti su ankstesniu atkuriamu išsaugojimu, o niekada su originalia įvestimi

Dar viena pamoka iš to paties leidimo apie tai, ką praėjęs patikrinimas įrodo ir ko ne. PDF/X-6 testinis failas iškvietė CharProcs.DeleteValue('A'), kuris atlaisvino tiesiogiai laikomą glifų srautą, tada vėl įterpė tą pačią rodyklę ir atskirai vieną tiesioginį ExtGState objektą perdavė ir resursų žodynui, ir šablonui. Atitikties validuotojas praeidavo su pertraukomis dėl to naudojimo po atlaisvinimo ir dvigubos nuosavybės, nes skaitė tai, ką atlaisvinta atmintis atsitiktinai laikė. Kai struktūrinis patikrinimas mirga, pažiūrėkite į testinės įvesties nuosavybę, o ne į validuotoją. Atkuriama išvestis tą discipliną padaro pigesnę: kai du išsaugojimai yra identiški baitas į baitą, vienintelis likęs mirgėjimo šaltinis yra pats objektų grafas, ir struktūrinis skirtumas nuo katalogo žemyn jį ras

Čia aprašytos ReproducibleOutput, DeterministicDictionaryOrder ir šifravimo savybės platinamos standartiniame HotPDF Delphi Component, skirtame Delphi ir C++Builder, ir ta pati vėliavėlė varo pačios bibliotekos regresijos korpusą, tad elgsena, kurią gaunate testų rinkinyje, yra ta elgsena, su kuria komponentas yra testuojamas