Technisch artikel

Deterministische PDF-ID in Delphi: reproduceerbare builds

Genereer dezelfde factuur twee keer uit hetzelfde sjabloon met dezelfde data, en diff de twee PDF's. Twee plekken verschillen: de hex-strings in de trailer-/ID-array en de ModDate-string in de documentinformatiedictionary, allebei van de klok. In losLab PDF Library maakt SetDeterministicDocumentID(1) plus een vaste ModDate de bestanden byte-identiek, voor een document zonder per-run XMP-UUID's of bijlagen

De voor de hand liggende eerste poging is de datums vastpinnen en opnieuw draaien. CreationDate en ModDate matchen nu, en de hashes verschillen nog steeds, nu alleen in twee hex-strings van 32 cijfers vlak voor het einde van het bestand, binnen /ID [<…> <…>]. Die identifier wordt apart van de metadata berekend, en totdat hij niet meer van het opslagtijdstip afhangt raakt een buildcache die op de bestandshash sleutelt nooit raak en houdt een content-addressable store één blob per run in plaats van één per document

Waar de trailer-ID-array voor dient

De trailer-/ID identificeert een bestand; hij is geen checksum van zijn inhoud. ISO 32000-1 §14.4 definieert hem als een array van twee bytestrings. De eerste is de permanente identifier, toegekend bij de creatie van het document en bedoeld om elke latere bewerking te overleven. De tweede is de veranderende identifier, die een writer verversst telkens als hij het bestand wijzigt. Een documentmanagementsysteem vergelijkt het paar om te beslissen of twee bestanden revisies van één document zijn of twee ongerelateerde documenten. Tabel 15 in §7.5.5 maakt de invoer verplicht wanneer de trailer /Encrypt draagt en optioneel verder, en §14.4 merkt op dat hem weglaten workflows in de weg kan zitten die op bestandsidentiteit leunen

De specificatie zegt nooit hoe de waarde moet worden berekend. Ze suggereert een MD5-digest over dingen als de huidige tijd, het bestandspad, de bestandsgrootte en de waarden in de informatiedictionary, en de klok is het ingrediënt dat het resultaat uniek maakt. Uniciteit is precies wat je van een identiteitsmarker wilt en precies wat reproduceerbaarheid sloopt. Standaard volgt losLab PDF Library die suggestie in zijn eenvoudigste vorm, een MD5 van de huidige timestamp, dus twee documenten die een seconde uit elkaar worden gemaakt krijgen verschillende permanente identifiers, ook als alle andere bytes matchen

Diagram dat identieke Delphi-buildruns vergelijkt waarvan de standaard op wall-clock gebaseerde PDF-trailer-ID's verschillen, tegenover byte-identieke deterministische uitvoer
De standaard trailer-id vouwt de systeemklok in de bestandsidentiteit, terwijl deterministische modus die afleidt uit een stabiele seed

Een reproduceerbare identifier aanzetten

Deterministische modus is een schakelaar per document en staat standaard uit, dus bestaande output verandert niet totdat je erom vraagt. SetDeterministicDocumentID neemt 0 of 1 en geeft 1 terug wanneer de waarde is geaccepteerd, 0 voor al het andere; GetDeterministicDocumentID meldt de huidige instelling. SetDocumentIDSeed levert een expliciete seedstring aan, en met een seed in place is de identifier een MD5 van die string, weggeschreven in beide elementen van de array. GetDocumentFileID leest /ID[0] na de save terug, handig voor logging of voor een testassertie

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);        // direct na Create
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetInformation(7, 'D:20260101000000Z');   // CreationDate
    Lib.SetInformation(8, 'D:20260101000000Z');   // ModDate
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;          // dezelfde waarde bij elke run
  finally
    Lib.Free;
  end;
end;

De identifier wordt berekend op opslagtijd. De schakelaar laat in een documentbuild omslaan werkt nog steeds, en een gewijzigde seed komt bij de volgende volledige save in het bestand: opslaan met seed A, nog eens opslaan met seed B, en de twee uitvoeren dragen verschillende identifiers, terwijl terug naar seed A je de originele waarde weer geeft

Zet de schakelaar wel direct na het aanmaken van het object aan. Fonts, afbeeldingen, graphics states en andere paginaresources krijgen namen in de resource dictionary van de pagina, en zolang deterministische modus aan staat komen die namen uit een teller. Alles wat vóór de schakelaar werd toegevoegd houdt een willekeurig gegenereerde naam, wat de identifier stabiel laat maar elke run een paar bytes in de contentstreams verandert

Waarom verandert de identifier nog steeds als er geen seed is gezet?

Omdat de informatiedictionary deel uitmaakt van de hash. Laat de seed leeg, of geef een lege string aan SetDocumentIDSeed om één te wissen, en losLab PDF Library leidt de identifier af uit het bestand zelf: hij jaagt de geserialiseerde body door SHA-256 tijdens het schrijven en gebruikt de eerste 16 bytes van de digest voor beide elementen van /ID. Elke wijziging in pagina-inhoud verandert de identifier, en dat is het hele punt. De vangst is dat CreationDate en ModDate ook in die body leven, en de saveroutines stempelen ModDate met de huidige tijd tenzij de aanroeper hem heeft gezet

Beslissingsstroom van PDF Library for Delphi die toont dat SetDocumentIDSeed voorrang krijgt op de afgeleide documentstatus-seed die voor deterministische PDF-id's wordt gebruikt
Een expliciete seed overschrijft al het andere, terwijl het afgeleide pad info dictionary-vermeldingen hasht, inclusief CreationDate en ModDate
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed('');   // leid /ID af uit de opgeslagen bytes
// De body-hash dekt de Info dictionary, dus de datums moeten
// build-invoeren zijn. Sleutel 8 verhindert ook dat de save ModDate stempelt.
Lib.SetInformation(2, 'Quarterly Report');        // Title
Lib.SetInformation(5, 'reporting-service 4.2');   // Creator
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

Sleutel 8 doet hier dubbel werk, en het is de regel die mensen het vaakst weglaten. ModDate via SetInformation zetten markeert de datum als door de aanroeper geleverd, en vanaf dan laten SaveToFile en SaveToString hem met rust. Zonder schrijft elke save een verse timestamp, verandert de gehashte body, en verandert de inhoudsafgeleide identifier mee. Neem de datums uit het bronrecord of uit een vast epoch. Now hoort niet thuis in een reproduceerbare build

Een paar dingen wordt met opzet buiten de hash gehouden. Willekeurig gegenereerde resourcenamen en de UUID in een XMP-xmpMM:InstanceID-invoer worden vóór het hashen genormaliseerd, dus twee runs die alleen in die tokens verschillen krijgen dezelfde /ID. De bestandsbytes verschillen daar wel, dus een PDF/A-achtig document met een verse instance-UUID per run geeft je een reproduceerbare identifier maar geen byte-identiek bestand. Ingesloten bijlagen zijn een andere bron van drift: AddAssociatedFileFromString stempelt de eigen /ModDate van de bijlage met de huidige tijd, en die datum wordt gehasht als elke andere bodybyte

Expliciete seed of inhoudshash

Voor build-artefacten zouden wij de seed leeg laten. Een inhoudsafgeleide identifier verandert precies wanneer de bytes veranderen, dus een cache die op hem sleutelt kan nooit een verouderd bestand serveren, en je krijgt geen tweede sleutel die synchroon moet blijven met de data. Een expliciete seed past bij een andere taak: een downstream archief dat /ID[0] als documentidentiteit behandelt en dezelfde identifier verwacht over regeneraties van dezelfde factuur heen, ook nadat het sjabloon is veranderd. Dat past beter bij de bedoeling van §14.4, tegen de prijs dat twee zichtbaar verschillende bestanden een identifier kunnen delen

Eén gevolg van de inhoudsroute verdient een bewuste beslissing. Laad je een bestaande PDF en sla je hem volledig op met deterministische modus aan, dan wordt de inkomende /ID weggegooid en hangt de nieuwe alleen af van wat er wordt geschreven. Twee bronbestanden die alleen in hun trailer-identifiers verschillen komen met dezelfde /ID uit de bus. Dat is wat een normaliserende buildstap wil. Bewerk je het document van een klant en moet zijn identiteit overleven, dan laat je deterministische modus voor die taak uit of slaat je hem incrementeel op

Waarom breekt het herschrijven van de ID een versleutelde PDF?

Omdat /ID[0] in een versleuteld document sleutelmateriaal is. ISO 32000-1 §7.6.3.3 algoritme 2 voedt het eerste element van de bestandsidentifier in de encryptiesleutelberekening voor de standaard security handler bij revisies 2 tot en met 4, samen met het aangevulde wachtwoord, de /O-waarde en de permissiebits. Die sleutel levert vervolgens de /U-string op die een reader bij het openen checkt. De sleutel wordt afgeleid op het moment dat je Encrypt aanroept of een versleuteld bestand laadt, vóór elke save, dus de identifier tijdens de save vervangen zou een keurig gevormd bestand opleveren waarvan de wachtwoordcontrole voor iedereen faalt, jijzelf inbegrepen

losLab PDF Library verversst de deterministische identifier daarom alleen bij onversleutelde volledige saves. Een versleuteld document houdt de /ID die het al had, en de schakelaar heeft geen effect op dat pad. Zo'n bestand heropenen met het gebruikerswachtwoord werkt zoals gewoonlijk. Stop ook geen versleutelde output in een bytevergelijking: AES-versleuteling onder §7.6.2 begint elke versleutelde string en stream met een willekeurige initialisatievector van 16 bytes, dus twee versleutelingen van hetzelfde document matchen nooit byte voor byte. Heb je daar een regressiecontrole nodig, decrypt allebei en vergelijk wat je terugkrijgt. Revisieafhandeling en permissiebits staan dieper uitgewerkt in de rondwandeling van PDF-versleuteling en permissieaudits

PDF Library for Delphi: Veiligheidsrailkaart van de deterministische PDF-id, met dekking van volledige opslagen, versleutelde documenten, incrementele updates en het versleutelingsherstelpad
Alleen onversleutelde volledige opslagacties mogen de id verversen, omdat versleutelde bestanden /ID[0] als sleutelmateriaal behandelen en toegevoegde revisies gekoppeld moeten blijven

Incrementele saves houden de originele identifier

Een incrementele update laat elke eerdere byte van het bestand op zijn plek en plakt een nieuwe revisie erachter aan. De permanentie van /ID[0] vertelt een consument dat de toegevoegde revisie bij hetzelfde document hoort als de voorgaande. Hem herschrijven zou de revisies die al in het bestand staan tegenspreken en zou handtekeningworkflows ondermijnen, want een handtekening dekt een bytebereik van één specifieke revisie. Append-modus raakt de identifier in deterministische modus nooit aan, wat het gedrag dat in het artikel over PDF-incrementele updates en append to stream staat beschreven intact houdt

De output over tijd reproduceerbaar houden

Elke latere wijziging aan de generator is een kans om een klokwaarde terug in het bestand te stoppen: een watermerk dat de generatietijd printt, een codepad dat een bestand toevoegt, een andere save-optie. Save-opties tellen net zo zwaar als inhoud: objecten in compressed object streams stoppen levert een compleet andere bestandsindeling op dan de klassieke cross reference tabel, zoals uitgelegd in de notities over object streams en cross reference streams, dus houd die instellingen per pijplijn vast. De goedkoopste verdediging is een test die hetzelfde document twee keer in het geheugen bouwt en de bytes vergelijkt

function BuildQuote(const Seed: WideString): AnsiString;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed(Seed);
    Lib.SetInformation(7, 'D:20260101000000Z');
    Lib.SetInformation(8, 'D:20260101000000Z');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Quote 8812');
    Result := Lib.SaveToString;
  finally
    Lib.Free;
  end;
end;

// Twee onafhankelijke builds moeten één bytereeks opleveren
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

Dezelfde harness kan een seriele en een parallelle RecompressStreams-run op dezelfde bron vergelijken: laad hem in twee TPDFlib-instanties, zet deterministische IDs aan en pin ModDate op allebei, zet SetOptimizationWorkers(1) op de één en een hoger aantal workers op de ander, en diff de twee SaveToString-resultaten. Met de klok uit beide bestanden komt elk resterend verschil uit de recompressie zelf

Reproduceerbare trailer-identifiers, metadatacontrole en incrementeel opslaan maken allemaal deel uit van losLab PDF Library voor Delphi en C++Builder, waarvan de referentie ook de hier genoemde versleutelingsopties opsomt