Technisch artikel

DocMDP en FieldMDP: PDF-revisies auditen in Delphi

Een ondertekende PDF die na ondertekening is gewijzigd, is niet automatisch kapot. ISO 32000-1 staat incrementele updates toe bovenop een handtekening, en slechts enkele daarvan doorbreken het beleid dat de ondertekenaar heeft ingesteld. HotPDF Component voor Delphi en C++Builder beantwoordt die vraag met AnalyzeLoadedSignatureRevisions, die elke revisie na ondertekening classificeert en tegen DocMDP en FieldMDP beoordeelt. Het scenario is bekend bij iedereen die contractsoftware uitlevert: je klant ondertekent een koopovereenkomst, stuurt hem uit, en krijgt hem terug met een bijlagepagina eraan vastgeplakt. De reader toont een gele balk die zegt dat de handtekening intact is maar dat het document gewijzigd is sinds ondertekening, en niemand in de kamer kan zeggen of dat een normale contrasigneringsworkflow is of iemand die stilletjes een ondertekend contract bewerkt

Wat telt als een legale wijziging na ondertekening?

Een wijziging is legaal wanneer de semantische categorie ervan binnen de toestemming valt die de certificerende handtekening heeft verklaard. ISO 32000-1 §12.8.2.2 definieert de DocMDP-transform met een /P-waarde van 1, 2 of 3: 1 staat helemaal geen wijzigingen toe, 2 staat formulier invullen en ondertekenen toe, 3 staat formulier invullen, ondertekenen en annoteren toe. HotPDF stelt die bloot als THPDFDocMDPPermission-waarden dmpNoChanges, dmpFormFillAndSign en dmpFormFillSignAndAnnotate, met dmpNone gereserveerd voor inspectieresultaten zonder enige DocMDP-transform

De categorieën zijn geordend, en die ordening is de motor van de hele controle. THPDFRevisionModificationLevel doorloopt rmlNone, rmlLongTermValidation, rmlFormFillAndSign, rmlAnnotations, rmlOther, bewust zo gerangschikt dat een hoger ordinaalgetal nooit minder restrictief is. Een heel document reduceert tot het maximale niveau dat over elke revisie na de handtekening is waargenomen, en de DocMDP-vergelijking wordt een enkele integer-test. Eén nuance telt vroeg: bij dmpNoChanges accepteert de analyse nog steeds rmlLongTermValidation. Het toevoegen van DSS- en VRI-validatiemateriaal of een documenttijdstempel aan een gecertificeerd bestand is onderhoud van de handtekening, geen wijziging van het document, en dit als een overtreding behandelen zou elke workflow voor langetermijnarchivering die er bestaat kapotmaken

Hoe herbouwt HotPDF de revisieketen?

Structureel, niet heuristisch. Volgens ISO 32000-1 §7.5.6 voegt een incrementele update een nieuwe cross-reference-sectie toe waarvan /Prev naar de vorige wijst, dus leest HotPDF startxref vanaf het einde, parseert de sectie daar, volgt /Prev terug in de tijd en herhaalt dit, en levert de secties oudste eerst. Twee veiligheidslimieten zitten in die lus en beide zijn het waard om te kennen bij het triëren van een bestand dat faalt: een /Prev die naar een al bezochte offset wijst, beëindigt de doorloop met een expliciete cyclusdiagnose in plaats van te blijven ronddraaien, en een keten langer dan duizend revisies wordt ronduit geweigerd. Beide verschijnen in Analysis.Issue waarbij de functie False retourneert, en geen van beide zou moeten worden weggemoffeld, want een cyclische /Prev is een misvormd of vijandig bestand, geen ongewoon bestand

Vier historische vormen komen voor in echte documenten en alle vier worden afgehandeld: traditionele xref-tabellen regel voor regel geparseerd, cross-reference-streams gedecomprimeerd en gedecodeerd via hun /W- en /Index-velden, hybride-referentiebestanden waarvan de traditionele trailer een /XRefStm-sleutel draagt die geparseerd en samengevoegd wordt in dezelfde revisie (het Office-producer-geval, behandeld in het artikel over hybride cross-reference-streams), en objecten die binnen een ObjStm-container leven, wat ertoe doet omdat een moderne update het gewijzigde dictionary meestal in een gecomprimeerde stream stopt in plaats van het direct te schrijven, zoals beschreven in het stuk over object streams en incrementele updates. De handtekening verankert de splitsing: /ByteRange[2] + /ByteRange[3] wordt SignedRevisionLength, en elke sectie op of voorbij die offset is na ondertekening. Of het bytebereik nog correct hasht is een aparte vraag, beantwoord door VerifyLoadedSignature en behandeld in het artikel over het verifiëren van digitale PDF-handtekeningen

Hoe wordt elk gewijzigd object geclassificeerd?

Classificatie draait per object en propageert dan langs referenties. Voor elk objectnummer dat een sectie na ondertekening raakt, leest HotPDF de nieuwe body en de body zoals die stond in de ondertekende momentopname; een identieke body is rmlNone, omdat producers wel degelijk objecten herschrijven zonder ze te wijzigen. De herkenners zijn bewust nauw. Een object /Type /DocTimeStamp, of een waarvan /SubFilter gelijk is aan ETSI.RFC3161, is rmlLongTermValidation, evenals alles bereikbaar vanuit de /DSS-boom van de catalogus; een /Type /Sig-dictionary is rmlFormFillAndSign. Voor containers is de test welke sleutels bewogen zijn, niet wat het object is: de catalogus mag alleen /DSS, /Extensions of /AcroForm krijgen of wijzigen; het AcroForm-dictionary alleen /Fields, /SigFlags, /NeedAppearances, /DR, /DA of /Q; een pagina alleen /Annots; een veld of widget alleen /V, /AP, /AS of /M. Alles buiten die verzamelingen valt terug op rmlOther, en zo wordt precies de toegevoegde bijlagepagina gevangen: het toevoegen van een pagina herschikt de paginaboom op manieren die geen whitelist afdekt, en geen enkele mate van legitiem formulier invullen lijkt daarop

Vervolgens propageren de niveaus, waarbij elke container het maximale niveau erft van de gewijzigde kinderen waarnaar het wijst, geïtereerd tot de toewijzing stabiliseert. Dit is wat verschijningsstreams laat werken. Een ingevuld tekstveld herschrijft /V en wijst naar een nieuwe /AP-stream, en die stream is op zichzelf een anonieme blob contentoperators zonder enig type om te herkennen; omdat het veld dat de stream bezit rmlFormFillAndSign is, erft de stream hetzelfde niveau in plaats van door te vallen naar rmlOther. Dezelfde propagatie draagt DSS-context over naar certificaat- en intrekkingsstreams die anders onclassificeerbaar zouden zijn

Waarom telt een onleesbaar object als overtreding?

Omdat het alternatief een validator is die verslagen wordt door iets te schrijven dat hij niet begrijpt. Drie situaties eindigen zonder beroep bij rmlOther in HotPDF: een object waarvan de body niet uit de revisie gelezen kon worden, een object dat de revisie als vrijgegeven markeert, en een object dat met geen van de bovenstaande herkenners overeenkomt. Elk registreert een specifieke diagnose in het revisieveld Issue, zodat een operator kan zien welk objectnummer tot het oordeel leidde

Vrijgeven is de scherpste van de drie. Een revisie na ondertekening die een eerder gedefinieerd object als vrij markeert, heeft inhoud verwijderd uit een ondertekend document, en geen enkel permissieniveau onder §12.8.2.2 staat dat toe; de objectnummers landen in FreedObjectNumbers en de revisie wordt verhoogd naar rmlOther. Onleesbare objecten volgen dezelfde logica om een andere reden. Een validator die een object niet kan parseren, heeft geen basis om het onschuldig te noemen, en het eerlijke antwoord daarop is geen stilte. Het rapporteren van een ongewone maar onschadelijke constructie als overtreding kost een menselijke review; de omgekeerde fout levert een ondertekend contract met een onopgemerkte wijziging erin

Het oordeel lezen in Delphi

De aanroep is kort. Laad het document, kies een handtekeningindex, lees de record; de parameterloze overload heropent het bestand waaruit het document geladen werd, en de TStream-overload neemt door de aanroeper geleverde bytes en herstelt de streampositie voordat hij terugkeert. PolicyCompliant is de enkele boolean die de meeste aanroepers willen, waarin drie onafhankelijke beslissingen samenkomen: de structurele geldigheid van de permissiedictionaries, DocMDPCompliant, en FieldMDPCompliant. Houd de onderdelen zichtbaar in je UI in plaats van ze samen te vouwen, en merk op dat een document zonder DocMDP-transform DocMDPCompliant op True laat staan, aangezien een gewone approval-handtekening geen beleid verklaart dat overtreden kan worden en de geaggregeerde ModificationLevel dan beschrijvend is in plaats van een oordeel

var
  Pdf: THotPDF;
  Analysis: THPDFSignatureRevisionAnalysis;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-countersigned.pdf') > 0 then
    begin
      if Pdf.AnalyzeLoadedSignatureRevisions(0, Analysis) then
      begin
        if Analysis.PolicyCompliant then
          Writeln('Post-signature changes stay inside the signing policy')
        else
          Writeln('Policy violation: ', string(Analysis.Issue));
      end
      else
        Writeln('Analysis could not run: ', string(Analysis.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

Voor triage wil je meestal de uitsplitsing per revisie in plaats van de samenvatting, omdat die vertelt wanneer in de documentgeschiedenis het misging. Elk item in Analysis.Revisions draagt zijn index in de keten, de cross-reference-offset waarop het geschreven is, zijn eigen wijzigingsniveau, en de betrokken objectnummers

const
  LevelNames: array[THPDFRevisionModificationLevel] of string =
    ('none', 'long-term validation', 'form fill and sign',
     'annotations', 'other');
var
  I: Integer;
begin
  Writeln(Format('%d revisions in chain, signature sits at index %d',
    [Analysis.TotalRevisionCount, Analysis.SignedRevisionIndex]));
  for I := 0 to High(Analysis.Revisions) do
    Writeln(Format('  rev %d at offset %d: %s (%d changed, %d freed) %s',
      [Analysis.Revisions[I].RevisionIndex,
       Analysis.Revisions[I].XRefOffset,
       LevelNames[Analysis.Revisions[I].ModificationLevel],
       Length(Analysis.Revisions[I].ChangedObjectNumbers),
       Length(Analysis.Revisions[I].FreedObjectNumbers),
       string(Analysis.Revisions[I].Issue)]));
end;

FieldMDP wordt apart beoordeeld, en dat is opzettelijk

Een document kan aan DocMDP voldoen en toch illegitiem zijn, en daarom is FieldMDPCompliant een aparte boolean in plaats van opgenomen in de niveauvergelijking. ISO 32000-1 §12.8.2.4 definieert de FieldMDP-transform, en §12.7.5.5 het bijbehorende item /SigFieldLock, om benoemde formuliervelden te bevriezen op het moment van ondertekening, zelfs waar het document als geheel formulier invullen nog steeds toestaat. Een veld invullen is een niveau-2-actie; een door de ondertekenaar vergrendeld veld invullen is een overtreding ongeacht het niveau. HotPDF leest de scope in THPDFFieldLockAction als flaAll, flaInclude of flaExclude, met flaNone voor resultaten zonder enig vergrendelingsbeleid, en de namen in Permissions.FieldNames: flaAll vergrendelt alles, flaInclude vergrendelt de vermelde namen, flaExclude vergrendelt alles behalve die. Eén detail telt bij het lezen van de resultaten: alleen velden die al aanwezig waren in de ondertekende momentopname worden gerapporteerd in ChangedFieldNames, omdat een veld dat volledig na ondertekening is aangemaakt geen ondertekende staat heeft om mee in tegenspraak te zijn, en dat wordt in plaats daarvan door het DocMDP-pad opgevangen

var
  Source: TFileStream;
  Analysis: THPDFSignatureRevisionAnalysis;
  I: Integer;
begin
  Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    if Pdf.AnalyzeLoadedSignatureRevisions(0, Source, Analysis) then
      if Analysis.Permissions.HasFieldMDP and (not Analysis.FieldMDPCompliant) then
        for I := 0 to High(Analysis.ChangedFieldNames) do
          Writeln('modified after locking: ',
            string(Analysis.ChangedFieldNames[I]));
  finally
    Source.Free;  // stream position was restored before the call returned
  end;
end;

Wat deze analyse niet zal vertellen

Ze verifieert geen handtekening. AnalyzeLoadedSignatureRevisions redeneert over structuur en permissies; of het ondertekende bytebereik nog steeds hasht naar de waarde in de CMS-blob, en of het certificaat van de ondertekenaar naar iets vertrouwds ketent, wordt beantwoord door VerifyLoadedSignature en VerifyLoadedSignatureWithTrust. Een bestand kan perfect beleidsconform zijn en cryptografisch waardeloos, dus de twee controles horen naast elkaar in elke echte acceptatiepoort. Ze leest ook geen intentie binnen contentstreams: een pagina waarvan de contentstream volledig vervangen is, wordt gevangen als een wijziging buiten de whitelist, maar de analyse zal je niet vertellen dat de vervanging een betalingsbedrag heeft omgewisseld. Een oordeel rmlOther betekent dat een mens moet kijken, niet dat er fraude plaatsvond, en een compliant oordeel betekent dat de wijziging binnen een toegestane categorie past, niet dat de wijziging gewenst was. Als je alleen nodig hebt wat de ondertekenaar heeft verklaard, zonder de revisiedoorloop, retourneert GetLoadedSignaturePermissions de beleidsdictionaries op zichzelf

Alles wat hier beschreven wordt draait native in Delphi en C++Builder zonder externe ondertekeningsdienst in de lus, wat het praktisch maakt om op elk binnenkomend document te draaien in plaats van alleen op de documenten die al verdacht werden. De volledige handtekening- en revisie-API, inclusief de permissie- en verificatiemethoden waarmee die samenwerkt, maakt deel uit van het HotPDF Component voor Delphi en C++Builder