Tekninen artikkeli

Toistettava PDF-tuloste: tavuidenttiset tallennukset

HotPDF Delphi Component tuottaa tavuidenttisen PDF-tulosteen tallennusten yli, kun ReproducibleOutput-ominaisuus on True: se kiinnittää Info-sanakirjan /CreationDate- ja /ModDate -arvot kiinteään päivämäärään, korvaa seinäkelloa seuraavan asiakirjatunnisteen siemennetyllä tai sisällöstä johdetulla tiivisteellä, vaihtaa vakioarvot jokaiseen satunnaistavuun, jonka AES-salauspolut muuten arpoisivat, ja lajittelee jokaisen serialisoimansa sanakirjan. Lippu on olemassa regressiosarjoja ja build-artefaktien vertailua varten, ei tuotantoasiakirjoja varten, ja juuri tuon rajan syyt ovat kiinnostava osa. Ominaisuuden käyttöön ajava skenaario on golden file -testi. Renderöit laskun, committoit PDF:n ja varmistat, että huomisen build tuottaa samat tavut. Se ei koskaan tuota. Tiedosto avautuu mainiosti jokaisessa katselimessa, teksti on identtinen, sivupuu on identtinen, ja diffi syttyy silti neljästä viidestä kohdasta. Jokainen, joka on yrittänyt laittaa PDF-generaattorin tavutason regressiotestiin, on törmännyt tähän seinään, eikä korjaus ole ”riisu aikaleimat” vaan tarkka selvitys jokaisesta kohdasta, jossa kirjoittaja turvautuu johonkin muuhun kuin itse asiakirjaan

Miksi saman PDF:n kaksi tallennusta eroavat toisistaan?

Saman asiakirjan kaksi tallennusta eroavat, koska PDF-kirjoittaja, HotPDF mukaan lukien, turvautuu neljään entropian lähteeseen, joilla ei ole mitään tekemistä sivun sisällön kanssa: seinäkello, asiakirjatunniste, kryptografinen satunnaislukugeneraattori ja sanakirjamerkintöjen muistijärjestys. Jokainen niistä on itsessään perusteltu. ISO 32000-1 haluaa ne sinne. Ne yksinkertaisesti tekevät tiedostosta sen funktion, milloin ja missä se kirjoitettiin, eivät sen mitä se sisältää

  • Kello. Info-sanakirja kantaa /CreationDate- ja /ModDate -arvot (ISO 32000-1 §14.3.3, taulukko 317) muodossa D:YYYYMMDDHHmmSS aikavyöhykepäätteellä varustettuna (§7.9.4), ja XMP-paketti toistaa saman hetken muodoissa xmp:CreateDate ja xmp:ModifyDate. HotPDF leimaa molemmat FCreationDate-kentästä, jonka konstruktori alustaa Now-arvoon, joten kaksi tallennusta eroavat sen sekunnin osalta jolloin ne kirjoitettiin
  • Tunniste. Trailerin /ID-taulukko (ISO 32000-1 §14.4) sisältää pysyvän tunnisteen ja muokkaustunnisteen. HotPDF:n oletusresepti tiivistää ensimmäiseen alkioon tiedostonimen yhdessä kuluvan ajan kanssa millisekunnin tarkkuudella ja toiseen alkioon tuon tiivisteen plus GetTickCount-arvon. Kaksi tunnistetta, kaksi tuoretta arvoa joka ajolla
  • Satunnaistavut. Standarditurva riippuu tunnisteesta ja aidosta satunnaisuudesta. AES-256:ssa tiedoston salausavain, validointi- ja avainsuolat sekä jokainen CBC-alustusvektori vedetään järjestelmän satunnaislähteestä (ISO 32000-2 §7.6.4.4.7 vaatii satunnaiset suolat). Koska /U, /UE, /O ja /OE lasketaan kaikki noista tavuista, salattu asiakirja muuttuu kokonaan silloinkin kun selväkielinen sisältö ei muutu. Vanhemmat algoritmit taittavat ensimmäisen /ID-alkion avaimeen (ISO 32000-1 §7.6.3.3, §7.6.3.4), joten pelkkä tuore tunniste riittää avaimen vaihtamiseen koko tiedostossa
  • Järjestys. PDF-sanakirja on järjestämätön kuvaus, ja kirjoittaja, joka käy läpi muistissa olevaa listaansa, tuottaa avaimet lisäysjärjestyksessä. Mikä tahansa koodipolku, joka rakentaa resurssisanakirjan eri järjestyksessä, tai mistä tahansa muusta asettelusta jäsennetty ladattu asiakirja tuottaa laillisen mutta tekstuaalisesti erilaisen tiedoston
Ne neljä entropian lähdettä, jotka saavat yhden asiakirjan kaksi HotPDF-tallennusta eroamaan: Now-arvosta leimattu FCreationDate ruokkii D:-päivämääriä ja XMP-pakettia, trailerin /ID tiivistää tiedostonimen, kellon ja GetTickCount-arvon, AES vetää avainmateriaalin järjestelmän satunnaislähteestä ja sanakirjat serialisoituvat muistin lisäysjärjestyksessä
Jokainen lähde on itsessään perusteltu ja ISO 32000-1 haluaa ne sinne, mutta yhdessä ne tekevät tiedostosta sen funktion, milloin ja missä se kirjoitettiin, eivät sen mitä se sisältää

Mihin ReproducibleOutput kiinnittää arvot?

ReproducibleOutput := True -sijoitus ennen BeginDoc-kutsua tai ennen SaveLoadedDocument-kutsua korvaa jokaisen neljästä lähteestä kiinteällä arvolla, ja se tekee sen samoissa koodipoluissa, jotka muuten turvautuisivat kelloon tai satunnaisgeneraattoriin, joten erillistä siivouskierrosta ei tarvita. Huomaa, mikä yllä olevasta listasta puuttuu: sisältö. Fontit, sivustreamit, kuvadata ja ristiviitetaulukko ovat jo deterministisiä samalla syötteellä; kohina asuu kokonaan metatiedoissa ja turvakerroksessa, minkä takia yksi kohdennettu ominaisuus pystyy poistamaan sen. Ominaisuuden oletusarvo on False, eikä mikään kirjastossa kytke sitä puolestasi päälle

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

BeginDoc-metodin sisällä toistettavuushaara sijoittaa FCreationDate := EncodeDate(2026, 1, 1) ja siementää asiakirjatunnisteen MD5CalcString('HotPDF-reproducible-seed') -kutsulla tiedostonimen ja kellon tiivisteen sijaan. Tuo yksi sijoitus kattaa molemmat Info-päivämäärät ja molemmat XMP-päivämäärät, koska kaikki neljä renderöidään samasta kentästä. Kun tiedosto lopulta kirjoitetaan, BuildDocumentIdentifiers kysyy trailerin tunnistetta ComputeCanonicalDocumentIdentifier-funktiolta: se vie koko objektigraafin ulos kanonisessa järjestyksessä, nollaa jokaisen löytämänsä D:-päivämäärämerkkijonon numerot, jotta aikaleimat eivät voi vuotaa takaisin tiivisteen kautta, ja ottaa tuloksesta MD5-tiivisteen. Molemmat /ID-alkiot saavat tuon arvon. Samaa sisällöstä johdettua tunnistetta käytetään, kun ladattu asiakirja salataan kulkematta koskaan BeginDoc-kutsun kautta, mikä on tilanne ActivateProtection-kutsussa LoadFromFile-kutsulla avaamallesi tiedostolle

Satunnaistavut ovat vähiten ilmeisin korvaus. AES-256:n avainrutiini käärii satunnaislähteensä paikalliseen apufunktioon, joka lipun alla kutsuu FillChar(P^, Count, $5A) -funktiota 32-tavuiselle tiedoston salausavaimelle ja jokaiselle 8-tavuiselle suolalle, ja AES-128:n ja AES-256:n merkkijono- ja stream-salausfunktiot vaihtavat AESGenerateRandomIV-kutsusta AESGenerateStaticIV-kutsuun, joka täyttää alustusvektorin arvolla 14 * (1 + I) paikassa I. Kun avain, suolat ja vektorit ovat kaikki kiinteitä, /U, /UE, /O, /OE ja jokainen salattu stream tulevat identtisiksi toisella ajokerralla. Lopuksi SaveToStream kytkee DeterministicDictionaryOrder-asetuksen päälle aina kun toistettavuuslippu on asetettu, ja serialisoija lajittelee silloin jokaisen sanakirjan lisäyslajittelulla avainten nimien raakojen tavujen mukaan, lyhyempi etuliite ensin, alkuperäinen indeksi tasatilanteen ratkaisijana. Se on sama järjestys, jota diagnostinen kirjoittaja käyttää ja jota kuvataan artikkelissa PDF:n käsin muokkaamisesta ja sen jälkeisestä korjaamisesta; toistettavuuslippu lainaa vain järjestyksen, ei muuta tuon kirjoittajan selväkielisestä asettelusta

Mihin ReproducibleOutput kiinnittää arvot HotPDF:ssä: luontipäivästä tulee EncodeDate 2026, 1, 1, trailerin tunniste tulee ComputeCanonicalDocumentIdentifier-kutsusta kanonisen graafin yli D:-numeroiden ollessa nollattuja, AES-avaimet ja -suolat täyttyvät $5A-tavuilla ja AESGenerateStaticIV täyttää jokaisen paikan, ja DeterministicDictionaryOrder lajittelee jokaisen sanakirjan
Korvaukset ajetaan samoissa koodipoluissa, jotka muuten turvautuisivat kelloon tai satunnaisgeneraattoriin, joten erillistä siivouskierrosta ei tarvita ja molemmat /ID-alkiot saavat saman sisällöstä johdetun arvon

Miksi kiinteä päivämäärä vuoti silti seinäkellon?

Version v2.752.2 korjaus on olemassa siksi, että kiinteä luontipäivä päätettiin alun perin konstruktorissa, eikä konstruktori voi tietää ominaisuutta, jota kutsuja ei ole vielä asettanut. Tavallinen kutsusekvenssi on Create, sitten ReproducibleOutput := True, sitten BeginDoc. Rakennushetkellä FReproducibleOutput on yhä False, joten FCreationDate sai Now-arvon ja piti sen. Tunniste ja satunnaistavut kiinnitettiin oikein, joten kaksi tiedostoa olivat samaa mieltä lähes kaikkialla ja eri mieltä täsmälleen kahdessa päivämäärämerkkijonossa ja kahdessa XMP-kentässä. Sijoituksen siirtäminen BeginDoc-metodin toistettavuushaaraan siemennetyn tunnisteen viereen vei päätöksen siihen pisteeseen, jossa ominaisuudella on lopullinen arvonsa

Tämän ohittanut regressiotesti on arvokkaampi kuin itse korjaus. Kaksi tallennusta, jotka molemmat ajetaan saman seinäkellosekunnin sisällä, kirjoittavat saman D:-merkkijonon vahingossa, ja tavuvertailu menee läpi bugilta, joka kaatuu millä tahansa hitaammalla koneella. Korjattu testi nukkuu 1100 ms kahden tallennuksen välissä, jotta PDF-aikaleima varmasti ylittää sekuntirajan, ajaa tapauksen tavalliselle, AES-128- ja AES-256-tulosteelle oikeilla salasanoilla kahdella salatulla variantilla ja vertaa kahta puskuria CompareMem-kutsulla ilmoittaen epäonnistuessa ensimmäisen eroavan siirtymän, jotta diffi osoittaa tiettyyn objektiin eikä koko tiedostoon. Tavuvertailu todistaa determinismin eikä mitään muuta, joten pidä erillinen väite, joka lataa salatun tulosteen käyttäjäsalasanalla ja lukee sivumäärän; muutos, joka tekee tiedostosta yhtä aikaa vakaan ja lukukelvottoman, ei saa luiskahtaa läpi vihreän diffin voimalla

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;

// testirungossa
A := SaveOnce(PathA);
TThread.Sleep(1100);          // pakota eri PDF-aikaleimasekunti
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');

Onko toistettava salattu PDF yhä turvallinen?

Ei ole. ReproducibleOutput-lipun alla salattu asiakirja ei ole suojattu missään merkityksellisessä mielessä, ja lipun on oltava pois päältä kaikelta, mikä poistuu testihakemistosta. AES-256:n tiedoston salausavain on kolmekymmentäkaksi $5A-tavua, suolat ovat kahdeksan $5A-tavua, ja alustusvektorit noudattavat julkisesti tunnettua aritmeettista kaavaa. Salasana portittaa yhä /UE- ja /OE-kääreet, mutta kääritty avain on vakio, joten kuka tahansa vakion tunteva voi purkaa jokaisen sisältöstreamin ilman salasanaa lainkaan. Suolien kiinnittäminen poistaa myös sen asiakirjakohtaisen ainutlaatuisuuden, johon ISO 32000-2 §7.6.4.4.7 nojaa pitääkseen samat salasanat tuottamasta samoja /U-merkkijonoja eri tiedostoissa. Lue AES-256-käyttöönottoartikkeli siitä, mitä salausominaisuudet lupaavat kun satunnaislähde on ehjä; toistettavuuslipun alla nuo lupaukset on keskeytetty

Tunnisteen kompromissi on hienovaraisempi. ISO 32000-1 §14.4 tarkoittaa toisen /ID-alkion muuttuvan jokaisella muokkauksella, jotta työkalut erottavat päivitetyn tiedoston sen esivanhemmasta, ja toistettava tallennus kirjoittaa saman arvon molempiin paikkoihin. Koska tuo arvo on kanonisen objektigraafin tiiviste, kaksi eri sisältöistä asiakirjaa saa silti eri tunnisteet, mikä on parempi kuin vakio. Mutta siemen, jota BeginDoc käyttää avainten johtamiseen, on sama merkkijono jokaiselle asiakirjalle jokaisella koneella, ja lukija, joka erottaa tiedostoja /ID-arvon perusteella, esimerkiksi huomautusvälimuisti tai lomakedatan oheistiedosto, sekoittaa keskenään jokaisen toistettavan tiedoston, joka sattuu tiivistymään samalla tavalla

Mitä lippu ei kata?

ReproducibleOutput poistaa sen entropian, jonka kirjoittaja tuottaa itse; se ei pysty poistamaan entropiaa, joka tulee ympäristön kautta tai koodipoluista joita se ei hallitse, ja kolmeen niistä on helppo kompastua

  • Aikavyöhykepääte. _DateTimeToPdfDate liittää perään paikallisen UTC-siirtymän, joten D:20260101000000+08'00' yhdellä build-agentilla ja D:20260101000000-05'00' toisella ovat eri tavuja samalle kiinteälle päivämäärälle. Toistettavuus pätee saman koneen ajokertojen yli tai koneiden yli, jotka jakavat aikavyöhykkeen; kiinnitä agentin vyöhyke, jos golden-tiedostosi matkustavat
  • Inkrementaaliset päivitykset. SaveIncrementalUpdate laskee muokkaustunnisteensa kohdepolusta, GetTickCount-arvosta ja kuluvasta ajasta ilman toistettavuushaaraa, koska inkrementaalinen osio on määritelmällisesti uusi muokkaus. Vertaa täysiä uudelleenkirjoituksia, älä perään liitettyjä deltoja
  • Läpikulkuoikopolku. SaveLoadedDocument kopioi normaalisti muokkaamattoman, salaamattoman lähdetiedoston tavu tavulta sen sijaan että serialisoisi sen uudelleen. Toistettavuuslippu poistaa tuon oikopolun käytöstä ja pakottaa täyden uudelleenkirjoituksen, jotta järjestys- ja tunnistesäännöt pätevät, mikä tarkoittaa että ladatun tiedoston toistettava tallennus on oletusta hitaampi eikä ole koskaan kopio syötteestä. Vertaa sitä edelliseen toistettavaan tallennukseen, älä koskaan alkuperäiseen
Mihin HotPDF:n toistettavat tallennukset pysähtyvät: _DateTimeToPdfDate liittää yhä paikallisen UTC-siirtymän, joten golden-tiedostot eroavat aikavyöhykkeiden yli, SaveIncrementalUpdate-kutsulla ei ole toistettavuushaaraa koska delta on uusi muokkaus, ja läpikulkuoikopolku on poistettu käytöstä, joten ladattu tiedosto kirjoitetaan aina täysin uudelleen
Toistettavuus pätee saman koneen ajokertojen yli tai koneiden yli, jotka jakavat vyöhykkeen, ja toistettavaa tallennusta pitäisi verrata edelliseen toistettavaan tallennukseen, ei koskaan alkuperäiseen syötteeseen

Vielä yksi oppi samasta julkaisusta siitä, mitä läpäisty tarkistus todistaa ja mitä ei. Eräs PDF/X-6-testifixture kutsui CharProcs.DeleteValue('A') -metodia, joka vapautti suoraan pidetyn glyfstreamin, lisäsi sitten saman osoittimen takaisin ja antoi lisäksi yhden suoran ExtGState-objektin sekä resurssisanakirjalle että mallille. Vaatimustenmukaisuusvalidaattori meni läpi ajoittain tuolla use-after-freella ja kaksoisomistajuudella, koska se luki mitä vapautettu muisti sattui sisältämään. Kun rakennetarkistus välkkyy, katso testisyötteen omistajuutta ennen kuin katsot validaattoria. Toistettava tuloste tekee tuosta kurista halvempaa: kun kaksi tallennusta ovat tavuidenttisiä, ainoa jäljellä oleva välkynnän lähde on objektigraafi itse, ja rakenteellinen diffi luettelosta alaspäin löytää sen

Tässä kuvatut ReproducibleOutput-, DeterministicDictionaryOrder- ja salausominaisuudet toimitetaan vakiona HotPDF Delphi Componentissa Delphille ja C++Builderille, ja sama lippu ajaa kirjaston omaa regressiokorpusta, joten se käyttäytyminen, jonka saat testisarjassa, on se käyttäytyminen jolla komponentti on testattu