Technisch artikel

Reproduceerbare PDF-output in Delphi: byte-identieke saves

Twee PDF's die dezelfde factuurcode één seconde na elkaar schrijft zijn bijna nooit hetzelfde bestand. Een binaire vergelijking struikelt eerst in de Info-dictionary, waar /CreationDate een seconde is opgeschoven, dan in het XMP-pakket, en dan aan de trailer-/ID. Zet AES-256 aan en de vergelijking valt op bijna elke stream om, want de file encryption key zelf was vers gegenereerd. De datums maskeren vóór de vergelijking herstelt de eerste twee verschillen en geen van de anderen. Een golden-file-test wordt pas betrouwbaar wanneer elke waarde die de writer ergens anders vandaan haalt dan uit het document, vaststaat op het moment waarop hij wordt gelezen

Vier waarden die nooit uit het document kwamen

HotPDF Delphi Component vult, zoals elke conformerende PDF-writer, vier soorten waarden van buiten de pagina-inhoud: de klok, de documentidentifier, de cryptografische randombron en de in-memory volgorde van dictionary-entries. ISO 32000-1 heeft voor elk daarvan een reden

  • De klok. De Info-dictionary draagt /CreationDate en /ModDate (ISO 32000-1 §14.3.3, tabel 317) als D:YYYYMMDDHHmmSS-strings met een tijdzonesuffix (§7.9.4), en het XMP-pakket herhaalt hetzelfde moment als xmp:CreateDate en xmp:ModifyDate. Allemaal worden ze gerenderd uit één aanmaaktimestamp dat de component uit Now neemt op het moment van constructie
  • De identifier. De trailer-/ID-array (§14.4) bevat een permanente identifier en een modificatie-identifier. Standaard is de eerste een MD5 van de bestandsnaam plus de huidige tijd op milliseconden nauwkeurig, en de tweede hasht die waarde opnieuw samen met de system tick count
  • De random bytes. Een AES-256-document heeft een 32-byte file encryption key nodig, een 8-byte validation salt en een 8-byte key salt voor /U, nog een paar voor /O, vier filler-bytes binnen /Perms, en een 16-byte initialisatievector vóór elke versleutelde string en stream. ISO 32000-2 §7.6.4.4.7 wil dat de salts random zijn. RC4 en AES-128 leiden hun sleutel af uit de wachtwoorden en het eerste /ID-element (ISO 32000-1 §7.6.3.3), dus een verse identifier alleen is al genoeg om het hele bestand opnieuw van sleutelmateriaal te voorzien
  • De volgorde. Een PDF-dictionary is een ongeordende mapping (§7.3.7). Een writer die zijn in-memory lijst afgaat emitteert keys in de volgorde waarin de code ze toevallig toevoegde, dus twee codepaden die dezelfde resource-dictionary in een andere volgorde opbouwen produceren een legaal maar tekstueel ander bestand
De vier entropiebronnen die twee HotPDF-saves van één document laten verschillen: het uit Now genomen aanmaaktimestamp voedt de D:-datums en het XMP-pakket, de trailer-/ID hasht bestandsnaam, klok en tick count, AES trekt sleutelmateriaal uit de system random source, en dictionaries serialiseren in volgorde van invoegen in het geheugen
Elke bron is op zichzelf legitiem en ISO 32000-1 wil ze daar hebben, maar samen maken ze van het bestand een functie van wanneer en waar het werd geschreven in plaats van wat het bevat

Alles wat overblijft volgt al uit de input: contentstreams, gecomprimeerde beelddata en xref-offsets herhalen zich voor dezelfde tekenaanroepen. Zelfs het ene veld dat er willekeurig uitziet is dat niet: het zesletterige voorvoegsel van een ingebed lettertype-subset (ISO 32000-1 §9.6.4, het ABCDEF+-deel van ABCDEF+Arial) wordt berekend uit de verzameling glyphs die de subset houdt, dus dezelfde tekst geeft bij elke run hetzelfde voorvoegsel. De ruis zit in de metadata en de beveiligingslaag, en dat is waarom één enkele property hem kan weghalen

Wat ReproducibleOutput vastpint

ReproducibleOutput := True zetten vóór BeginDoc vervangt elk van de vier bronnen door een vaste waarde, in dezelfde codepaden die anders naar de klok of de randomgenerator zouden grijpen. Er komt geen opruimronde achteraf. De property defaultt naar False, en niets in de bibliotheek zet hem voor u aan

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

Onder de flag zet BeginDoc het aanmaaktimestamp op 1 januari 2026, 00:00 lokale tijd, en omdat zowel de Info-datums als de XMP-datums uit die ene waarde worden gerenderd, dekt één toewijzing ze allemaal. Dezelfde tak seedt de interne documentidentifier vanuit een constante string in plaats van de bestandsnaam en de klok. Wanneer het bestand wordt weggeschreven, wordt de trailer-/ID berekend als een MD5 over een canonieke export van de hele objectgrafiek waarin eerst de cijfers van elke D:-datumstring op nul worden gezet, dus een timestamp kan niet stiekem via de hash terugkomen. Beide elementen van /ID krijgen die waarde. Een geladen document dat u versleutelt zonder ooit BeginDoc aan te roepen (zet ActivateProtection na LoadFromFile) krijgt dezelfde inhoudsafgeleide identifier

De random bytes worden vervangen door constanten. De AES-256 file key, alle vier de salts en de /Perms-filler worden gevuld met $5A, en elke CBC-initialisatievector, voor AES-128-strings en streams net zo goed als AES-256, wordt het patroon 14 * (1 + I) voor byte I. Dat patroon is met opzet niet nul. Een all-zero IV is het schoolboekzwakke voor CBC, en een regressiesuite hoort dat niet te normaliseren. Met sleutel, salts en vectoren vast komen /U, /UE, /O, /OE, /Perms en elke versleutelde stream bij de tweede run identiek uit de bus

Ten slotte zet SaveToStream DeterministicDictionaryOrder aan zodra de reproduceerbare flag staat, en wel voordat hij kiest tussen de linearized-, object-stream- en klassieke layout, dus ook output met LinearizeOutput of UseObjectStreams wordt gesorteerd. Keys worden op hun rauwe bytes geordend, een key die het voorvoegsel is van een andere komt eerst, en de oorspronkelijke positie breekt gelijke stand. Dat is de ordening die de diagnostische writer gebruikt, beschreven in het artikel over een PDF met de hand bewerken en daarna herstellen; de reproduceerbare flag leent de ordening en geen van die plain-text-layout van die writer. DeterministicDictionaryOrder op zichzelf aanzetten is een halve maatregel. De keys komen op een rij, maar de Info-datums en beide trailer-/ID-strings blijven de klok dragen, dus twee saves blijven op dezelfde vier plekken verschillen

Wat ReproducibleOutput in HotPDF vastpint: de aanmaakdatum wordt 1 januari 2026, de trailer-identifier is een MD5 van de canonieke objectgrafiek met D:-cijfers op nul, AES-sleutels en salts worden gevuld met $5A-bytes en elke IV-byte volgt 14 keer (1 + i), en DeterministicDictionaryOrder sorteert elke dictionary
De vervangingen draaien in dezelfde codepaden die anders naar de klok of de randomgenerator zouden grijpen, dus er is geen aparte opruimronde nodig en beide /ID-elementen krijgen dezelfde inhoudsafgeleide waarde

Een test die niet op geluk kan slagen

De datum wordt in BeginDoc vastgezet omdat de constructor draait voordat de aanroeper de kans heeft gehad de property te zetten. De normale volgorde is Create, dan ReproducibleOutput := True, dan BeginDoc, en alleen de laatste ziet de flag met zijn eindwaarde. Die ordening heeft een directe consequentie voor het testontwerp. Twee saves die binnen dezelfde wandklokseconde vallen schrijven bij toeval dezelfde D:-string, dus een snelle test slaagt of de datum nu vaststaat of niet, en faalt dan maanden later op een langzamere build-agent

De oplossing is goedkoop: wacht 1100 ms tussen de twee saves zodat de timestamp gegarandeerd eensecondegrens overschrijdt, draai het geval voor plain, AES-128 en AES-256-output met echte wachtwoorden op de versleutelde varianten, en meld de eerste afwijkende byte-offset in plaats van een kale true of false. Een offset wijst naar één object. Een boolean wijst naar het hele bestand

uses
  System.SysUtils, System.IOUtils, HPDFDoc;

function SaveOnce(const Target: string; Encrypt: Boolean;
  KeyType: THPDFKeyType): TBytes;
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.VerifySavedOutput := True;   // strikte herlaadronde direct na EndDoc
    if Encrypt then
    begin
      Pdf.OwnerPassword := 'owner';
      Pdf.UserPassword := 'user';
      Pdf.CryptKeyLength := KeyType; // aes128 of aes256
      Pdf.ActivateProtection := True;
    end;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Result := TFile.ReadAllBytes(Target);
end;

// Eigen helper: -1 bij gelijk, anders de eerste afwijkende offset
function FirstDifference(const A, B: TBytes): Integer;
var
  I, N: Integer;
begin
  N := Length(A);
  if Length(B) < N then
    N := Length(B);
  for I := 0 to N - 1 do
    if A[I] <> B[I] then
      Exit(I);
  if Length(A) <> Length(B) then
    Result := N
  else
    Result := -1;
end;

procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
  A, B: TBytes;
  Offset: Integer;
begin
  A := SaveOnce('run-a.pdf', Encrypt, KeyType);
  Sleep(1100);                     // forceer een andere timestampseconde
  B := SaveOnce('run-b.pdf', Encrypt, KeyType);
  Offset := FirstDifference(A, B);
  if Offset >= 0 then
    raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;

Roep CheckReproducible(False, aes128), CheckReproducible(True, aes128) en CheckReproducible(True, aes256) aan; op de kale run wordt het sleuteltype genegeerd. VerifySavedOutput verdient zijn plek in deze test. Met hem aan herlaadt EndDoc het bestand dat hij net heeft weggeschreven in een verse THotPDF-instantie met cross-reference recovery uitgeschakeld, met het gebruikerswachtwoord als het document beveiligd is, en gooit een exception als dat laden mislukt. Een wijziging die de output stabiel én onleesbaar maakt zou door een groene diff glippen; de herlaadronde houdt hem tegen

Is een reproduceerbare versleutelde PDF nog veilig?

Nee. Een document dat onder ReproducibleOutput is versleuteld heeft geen echte bescherming, en de flag hoort uit te staan voor alles wat de testmap verlaat. De AES-256 file key is tweeëndertig bytes $5A. Het wachtwoord bewaakt nog steeds de /UE- en /OE-wrappers, maar wat ze omwikkelen is een gepubliceerde constante, dus iedereen die die kent kan elke contentstream zonder wachtwoord ontsleutelen. Vaste salts nemen bovendien de uniciteit per bestand weg waarop ISO 32000-2 §7.6.4.4.7 vertrouwt om te voorkomen dat hetzelfde wachtwoord in twee bestanden dezelfde /U-string oplevert. Het AES-256-artikel beschrijft wat de encryptie-eigenschappen leveren met de randombron intact; onder de reproduceerbare flag geldt daarvan niets. Zet de property achter een test-only configuratiewaarde, niet achter een instelling die een productiecodepad kan bereiken

AES-GCM wordt anders behandeld. Met CryptKeyLength := aesgcm (alleen PDF 2.0) trekt de component het 8-byte nonce-prefix uit de system random source, wat ReproducibleOutput ook zegt, en telt daarna op voor elke versleutelde string of stream. Dat is het juiste gedrag. GCM faalt hard wanneer een nonce onder dezelfde sleutel herhaalt: twee cijferteksten lekken de XOR van hun leesteksten, en een aanvaller kan genoeg herstellen om authenticatietags te vervalsen. Een gepinte sleutel plus een gepinte nonce zou die herhaling zeker maken over elk bestand dat de suite produceert. Versleutelde golden files gebruiken daarom aes128 of aes256, die in CBC-modus draaien

De identifier is een mildere afweging. ISO 32000-1 §14.4 bedoelt dat het tweede /ID-element bij elke modificatie verandert, en een reproduceerbare save schrijft dezelfde waarde in beide slots. Omdat die waarde de objectgrafiek hasht, krijgt verschillende inhoud nog steeds verschillende identifiers. Een reader die een cache op /ID bouwt, een annotatieopslag of een form-data-sidecar bijvoorbeeld, blijft twee reproduceerbare saves van dezelfde inhoud als één bestand behandelen. In een testsuite is dat precies wat u wilt

Inputs die buiten de writer blijven

ReproducibleOutput dekt de entropie die de writer zelf introduceert. Een paar extra inputs komen van de buildmachine of van bewerkingen die per definitie een nieuw moment vastleggen

Waar reproduceerbare HotPDF-saves ophouden: PDF-datumstrings dragen nog steeds de lokale UTC-offset zodat golden files over tijdzones heen verschillen, SaveIncrementalUpdate bouwt een nieuwe modificatie-identifier omdat een delta een nieuwe modificatie is, en de passthrough-shortcut is uitgeschakeld zodat een geladen bestand altijd volledig wordt herschreven
Reproduceerbaarheid houdt stand over runs op één machine of over machines die een zone delen, en een reproduceerbare save hoort tegen een vorige reproduceerbare save te worden geduwd, nooit tegen de originele input
  • Het tijdzonesuffix. Elke PDF-datumstring eindigt op de lokale UTC-offset, dus dezelfde vaste datum leest als D:20260101000000+08'00' op de ene build-agent en als D:20260101000000-05'00' op een andere. Golden files houden stand over runs op één machine, of over machines die een zone delen. Reizen de bestanden tussen kantoren, pin dan de tijdzone van de agents
  • Incrementele updates. SaveIncrementalUpdate houdt de permanente identifier van de bron aan en bouwt een nieuwe modificatie-identifier uit de vorige, het doelpad, de tick count en de huidige tijd. Een aangehangen sectie is per definitie een nieuwe modificatie. Vergelijk volledige herschrijvingen, geen aangehangen delta's
  • De passthrough-shortcut. SaveLoadedDocument kopieert normaal een ongewijzigde, onversleutelde bron byte voor byte in plaats van hem opnieuw te serialiseren. Onder de flag wordt het bestand altijd herschreven zodat de ordenings- en identifierregels gelden, wat de save langzamer maakt en nooit een kopie van de input oplevert. Vergelijk hem met een vorige reproduceerbare save, nooit met het origineel
  • Timestamps die u zelf toevoegt. AddDocumentAttachment stempelt de /Params /CreationDate en /ModDate (ISO 32000-1 §7.11.4) van het ingebedde bestand met het moment van de aanroep, en een handtekening legt zijn ondertekeningstijd in /M vast. Die datums beschrijven gebeurtenissen waar de aanroeper om vroeg, dus de flag laat ze met rust. Houd attachments en handtekeningen buiten byte-level gevallen, of masker die strings in de vergelijking
  • Geïnstalleerde fonts. Een TrueType-subset wordt gesneden uit het fontbestand dat op de machine staat die de test draait. Twee agents met verschillende releases van Arial embedden andere glyph-programma's voor dezelfde tekst, en het subset-voorvoegsel kan daarmee meebewegen. Lever de fontbestanden mee met de build-image

Geladen documenten volgen dezelfde regels. Zet de property vóór het laden, doe uw bewerkingen, en sla op via SaveLoadedDocument; de output is een genormaliseerde herschrijving die u als referentie voor de volgende run kunt committen

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.ReproducibleOutput := True;   // vóór het laden zetten
    if Pdf.LoadFromFile('incoming.pdf') <= 0 then
      raise Exception.Create('incoming.pdf did not load');
    // ... bewerkingen onder test ...
    Pdf.SaveLoadedDocument('normalized.pdf');  // volledige herschrijving, gesorteerde keys
  finally
    Pdf.Free;
  end;
end;

Byte-identieke saves klemmen ook een flikkerende conformiteitscheck in de hoek. Zodra de writer stabiel is, reageert een check die de ene run slaagt en de volgende faalt op de objectgrafiek die de test bouwt, bijvoorbeeld één directe dictionary die aan twee parents wordt gegeven, dus inspecteer de testinput voordat u naar de validator wijst. Een structurele diff vanaf de catalog omlaag laat zien welk object tussen de twee runs is verplaatst

De ReproducibleOutput-, DeterministicDictionaryOrder-, VerifySavedOutput- en encryptie-eigenschappen die hierboven zijn gebruikt maken deel uit van de HotPDF Delphi Component, die voor zowel Delphi als C++Builder wordt geleverd