Technisch artikel

PDF-wijzigingen na ondertekening met PDFium analyseren

Om na te gaan wat er in een PDF veranderde nadat hij was ondertekend, biedt de PDFium Component voor Delphi en Lazarus TPdf.AnalyzeSignatureRevisions, een analyse van revisiewijzigingen na ondertekening die elke incrementele revisie herbouwt uit de oorspronkelijke bestandsbytes, elke latere objectwijziging afmeet aan de DocMDP- en FieldMDP-regels van die handtekening, en shadow-objectdefinities als apart risico meldt. De situatie waarop hij doelt kent iedereen die met contracten werkt: een gecertificeerd formulier gaat de deur uit, komt terug met twee extra incrementele saves, en elke handtekening verifieert nog steeds. Dat is verwacht, want een handtekening dekt alleen de bytes van zijn eigen revisie. De echte vraag is of die latere saves waren toegestaan, en een groen vinkje bij de handtekening beantwoordt die niet

Waarom kan de PDFium signature API niet tonen wat er na ondertekening veranderde?

De PDFium signature API kan wijzigingen na ondertekening niet tonen omdat hij alleen het handtekeningwoordenboek leest: /Contents, /ByteRange, /SubFilter en de DocMDP-permissiewaarde. PDFium heeft geen graaf van incrementele revisies, parseert geen FieldMDP-transformparameters en biedt geen diff op objectniveau tussen revisies, dus de analyzer in FPdfPades.pas werkt in plaats daarvan rechtstreeks op rauwe bytes. Dat heeft een praktisch gevolg waar u rekening mee moet houden in uw ontwerp. TPdf.AnalyzeSignatureRevisions leest de bytes die zijn bewaard toen het document werd geladen, nooit een kopie gemaakt met SaveAs, want een herschreven bestand heeft precies de revisiestructuur verloren die wordt geanalyseerd. Komt het document van een progressive bron die nog niet klaar is met downloaden, dan geeft het rapport SourceStatus = pvssIncomplete en Status = prasIndeterminate terug in plaats van een afgekap bestand te analyseren

Revisiegrenzen herbouwen vanuit startxref, xref-streams en /Prev

De analyzer herbouwt revisiegrenzen door elke startxref terug te volgen langs klassieke xref-tabellen, cross-reference-streams, hybrid-reference-invoeren /XRefStm en de keten /Prev, zoals gedefinieerd voor incrementele updates in ISO 32000-1 §7.5.6 en §7.5.8. De gedekte lengte van elke handtekening is het einde van zijn tweede ByteRange-span, en de analyzer mapt die lengte op de revisie waarbinnen zijn xref-sectie valt. Matcht geen enkele revisie, dan krijgt de handtekening prrCoveredRevisionNotFound en een status Indeterminate. Vervolgens wordt de toestand van elk object afgespeeld tot aan de gedekte revisie, en wordt elke latere xref-invoer vergeleken met die toestand. Dat telt zwaarder dan het klinkt: sommige writers herhalen de volledige xref-tabel bij elke incrementele save, en een invoer die nog naar hetzelfde ongewijzigde object wijst, wordt overgeslagen in plaats van als wijziging gemeld. Zonder die vergelijking zou een volstrekt legaal invullen van een formulier verdrinken in honderden nepwijzigingen

Hoe AnalyzeSignatureRevisions incrementele revisies herbouwt uit rauwe PDF-bytes in Delphi: de ByteRange van de handtekening eindigt binnen de gedekte revisie, de xref Prev-keten loopt terug langs elke latere save, de objecttoestand wordt afgespeeld tot de gedekte revisie, en ongewijzigde herhaalde invoeren worden overgeslagen in plaats van als wijzigingen gemeld
Een handtekening dekt alleen de bytes van zijn eigen revisie, dus de analyzer mapt de tweede ByteRange-span op een revisie en meet elke latere xref-invoer af aan de afgespeelde objecttoestand

Shadow-definities zijn het geval dat de meeste aandacht verdient. Een objectbody die binnen de byterange van een latere revisie voorkomt maar niet door de xref van die revisie wordt gerefereerd, is onzichtbaar voor een gewone viewer, en het is precies het soort staging waar shadow attacks op steunen: verborgen content wordt geplant vóór of na het ondertekenen en wordt later geactiveerd door een verwijzing om te zetten. AnalyzePadesSignatureRevisionsBytes registreert zo'n object als niet-authoritatieve wijziging met IsAuthoritative = False, beoordeelt het prdSuspicious ongeacht het permissieniveau en voegt prrUnreferencedObjectDefinition toe aan de risicoset. Twee verwante risico's dekken andere structurele trucs: prrDuplicateObjectDefinition afgaat wanneer één xref-sectie hetzelfde object meer dan eens noemt, en prrSignatureObjectRedefined wanneer een latere revisie een bestaand handtekeningobject herdefinieert

Een shadow-objectdefinitie binnen de byterange van een latere PDF-revisie: de objectbody bestaat maar geen enkele xref-invoer verwijst ernaar, dus viewers tonen hem nooit, en AnalyzeSignatureRevisions in PDFium Component registreert hem als niet-authoritatief, beoordeelt hem prdSuspicious en geeft prrUnreferencedObjectDefinition naast de risico's van duplicaat en hergedefinieerde handtekening
Verborgen content wordt geplant vóór of na het ondertekenen en later geactiveerd door een verwijzing om te zetten, en daarom wordt een niet-gerefereerde body verdacht bevonden ongeacht het DocMDP-permissieniveau
uses
  SysUtils, TypInfo, PDFium, FPdfPades;

const
  ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-returned.pdf';
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;
    Writeln('Revisions: ', Report.RevisionCount,
      '  Signatures: ', Report.SignatureCount,
      '  Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
        Ord(Report.Status)));
    for i := 0 to High(Report.Signatures) do
      with Report.Signatures[i] do
      begin
        Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
          [SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
           DocMdpPermission,
           GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
        for j := 0 to High(Changes) do
          Writeln(Format('  rev %d  obj %d  %s -> %s%s',
            [Changes[j].RevisionIndex, Changes[j].ObjectNumber,
             GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
             GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
             ShadowTag[not Changes[j].IsAuthoritative]]));
      end;
  finally
    Pdf.Free;
  end;
end;

Hoe worden DocMDP en FieldMDP afgedwongen per handtekening?

DocMDP en FieldMDP worden per handtekening apart afgedwongen, op de eigen gedekte revisie van die handtekening, dus een certificeringssignature en een latere goedkeuringssignature in hetzelfde bestand kunnen over dezelfde bewerking tot verschillende oordelen komen. Elk latere object wordt eerst geclassificeerd in een TPadesRevisionChangeKind op grond van zijn invoeren /Type, /Subtype en /FT en van de rol die het speelt in de grafen van pagina, formulier, annotatie en DSS. Alles wat /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia of /EmbeddedFile meedraagt wordt prckActiveContent. Het besluit volgt daarna ISO 32000-1 §12.8.2.2: bij P=1 is alles behalve cross-reference-data en validatiemateriaal verboden; P=2 staat invullen van formulieren en verdere handtekeningen toe maar verwerpt annotatiewijzigingen; P=3 staat ook annotaties toe. Pagecontent, documentstructuur, metadata, actieve content en verwijderde objecten zijn verboden op elk DocMDP-niveau, en worden prdSuspicious beoordeeld wanneer de handtekening helemaal geen DocMDP draagt, want een goedkeuringssignature verbiedt formeel niets maar de lezer ziet niet langer wat er is ondertekend

FieldMDP, uit ISO 32000-1 §12.8.2.4, verfijnt het besluit over formuliervelden verder. pfmaAll vergrendelt elk veld, pfmaInclude vergrendelt alleen de genoemde velden, en pfmaExclude vergrendelt alles behalve de genoemde velden. Om Include of Exclude toe te passen, lost de analyzer elk gewijzigd veld op naar zijn volledig gekwalificeerde naam via de keten /Parent en vergelijkt die met de vergrendellijst op exacte match, dus noem terminalveldnamen in de lijst in plaats van te verwachten dat een ouder_naam zijn kinderen dekt. Kan een naam niet worden opgelost of gebruikt de transform een actie die de parser niet herkent, dan wordt de wijziging prdIndeterminate en gaat prrFieldMdpUnresolved af. De besluiten per wijziging worden daarna worst-first opgeteld, met Suspicious boven Disallowed, Disallowed boven Indeterminate en Indeterminate boven Allowed, zodat één shadow-object elk aantal legitieme veldupdates overtreft

De beoordelingspijplijn die AnalyzeSignatureRevisions in Delphi toepast op elke wijziging na ondertekening: een TPadesRevisionChangeKind uit Type- en Subtype-invoeren, een DocMDP-besluit op de gedekte revisie van P=1 tot P=3, een FieldMDP-vergrendelingscontrole op volledig gekwalificeerde veldnamen, en een worst-first optelling van prdSuspicious tot prdAllowed
Eén shadow-object weegt op tegen elk aantal legitieme veldupdates omdat Suspicious boven Disallowed, Indeterminate en Allowed rankt, terwijl sommige risico's naast de status worden geregistreerd zonder haar te verlagen

Waarom komen sommige wijzigingen terug als Indeterminate in plaats van veilig?

Wijzigingen komen als Indeterminate terug zodra de analyzer niet kan bewijzen dat een wijziging is toegestaan, want bij een handtekeningcontrole mag een onbekende nooit als toegestaan worden gemeld. Eén veelvoorkomend geval wordt juist precies afgehandeld: langetermijnvalidatie voegt een /DSS toe en herschrijft de catalogus, wat anders onder P=1 als structurele wijziging zou tellen. De analyzer stript /DSS en /Extensions uit de oude en nieuwe cataloguswoordenboeken en vergelijkt de rest; verschilt er verder niets, dan geldt de herschrijving als update van validatiemateriaal en wordt ze toegestaan, zodat B-LT- en B-LTA-uitbreiding een certificeringssignature niet breekt. Andere gaten blijven met opzet open. Type-2-invoeren in een cross-reference-stream wijzen in gecomprimeerde objectstreams, en de analyzer expandeert objectstreams niet binnen deze securitygrens, dus die wijzigingen komen naar boven als prckCompressedObject met prrCompressedObjectUnresolved, verboden onder P=1 en anders Indeterminate. Harde budgetten van 1024 revisies, 1.000.000 objectnummers en 2.000.000 gemelde wijzigingen leveren prrResourceLimitExceeded op, en een gebroken xref-keten levert prrMalformedRevisionChain op; beide eindigen als Indeterminate, nooit als geslaagd

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // Sommige risico's worden geregistreerd zonder Status te wijzigen, dus test ze eerst
  if R.Risks * StructuralRisks <> [] then
    Exit('review: structural risk in the revision chain');
  case R.Status of
    prasNoLaterChanges: Result := 'accept: nothing was added after signing';
    prasAllowed:        Result := 'accept: every later change is permitted';
    prasDisallowed:     Result := 'reject: a change violates DocMDP or FieldMDP';
    prasSuspicious:     Result := 'reject: shadow or unconstrained content change';
    prasIndeterminate:  Result := 'review: the analyzer could not decide';
  else
    Result := 'not checked: no signatures or no original bytes';
  end;
end;

De volgorde in die poort is bewust. prrDuplicateObjectDefinition wordt op zichzelf aan de risicoset toegevoegd zonder Status te verlagen, en een FieldMDP-transform die niet te parsen is raakt de status pas zodra er werkelijk een formulierveld verandert, dus een poort die alleen naar Status kijkt kan bewijs missen dat het rapport al bevat. Bedenk ook wat het rapport níét claimt. TPadesRevisionAnalysisReport zegt niets over of de CMS-handtekening cryptografisch geldig is of of het certificaat van de ondertekenaar ketent naar een wortel die u vertrouwt. Revisieanalyse beantwoordt de vraag wat er na het ondertekenen gebeurde, en hoort naast structurele en trustvalidatie te staan, niet op hun plek

Seed values en MDP-vergrendelingen schrijven op het moment van ondertekenen

Dezelfde regels kunnen worden vastgelegd bij het ondertekenen via TPadesSignatureFieldOptions, het lid FieldOptions van zowel TPadesSignOptions als TPadesRemoteSignOptions. PDFium kan een widget aanmaken maar kan geen /SV, /Lock, een FieldMDP- of DocMDP-transform of het cataloguswoordenboek /Perms schrijven, dus de eigen incrementele PAdES-writer van de component produceert deze objecten binnen dezelfde xref-update als de handtekening. FieldName stelt de wortelveldnaam in, RequiredSeedValues wordt de /Ff-bits van het seed-value-woordenboek beschreven in ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations en AcceptableCertificates beperken wat een latere ondertekenaar mag kiezen, LockAction met LockFields schrijft een indirecte /SigFieldLock, en CertificationPermission van 1 tot 3 maakt van de handtekening een certificeringssignature. De DocMDP- en FieldMDP-transforms gaan beide in één array /Reference op de handtekeningwaarde, elk met /Data wijzend naar de catalogus

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // alleen formulier invullen en ondertekenen
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // vergrendel alleen deze velden
  SetLength(Options.FieldOptions.LockFields, 2);
  Options.FieldOptions.LockFields[0] := 'Total';
  Options.FieldOptions.LockFields[1] := 'IBAN';
  if not Pdf.SignPades('contract-certified.pdf', Options) then
    Writeln('Signing failed');
end;

Een paar details gaan gemakkelijk mis als u dit zelf in elkaar zet. Catalogus /Perms /DocMDP moet verwijzen naar het woordenboek van de handtekeningwaarde, niet naar de widgetannotatie, en de writer houdt de handtekeningwaarde daarom als eigen indirect object. Een bestaand woordenboek /Perms kan al gebruiksrechten /UR3 bevatten, dus kopieert de writer het en voegt /DocMDP in in plaats van het te vervangen, volgens het permissiewoordenboek in ISO 32000-1 §12.8.4. Een document dat al /DocMDP draagt weigert een tweede certificeringssignature met EPadesCrypto, en inconsistente opties doen dat ook: een Include- of Exclude-vergrendeling zonder veldnamen, een All-vergrendeling met een veldlijst, een juridische attestatie op een niet-certificeringssignature, of een punt in de wortelveldnaam. Remote signing voegt nog één regel toe, want het ondertekeningscertificaat is onbekend wanneer PreparePadesRemoteSignature draait: daar CertificateRequired zetten eist een expliciete lijst AcceptableCertificates, terwijl lokaal ondertekenen kan terugvallen op het opgeloste certificaat van de ondertekenaar

Revisieanalyse maakt de handtekeningtoolbox compleet in plaats van een deel ervan te vervangen. Begin met PDF-handtekeningen en PAdES-niveaus inspecteren met de PDFium Component om het woordenboek en het basisniveau te lezen, bekijk waarom validators PAdES-handtekeningen afwijzen voor de structurele falingen die aan elke revisievraag voorafgaan, en vouw het oordeel in een bredere PDF-beveiligingsrisicoaudit naast JavaScript- en embedded-file-controles. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions en de hier getoonde incrementele PAdES-writer worden geleverd met de PDFium Component voor Delphi, C++Builder en Lazarus