Technical Article

Post-Signature PDF Change Analysis with PDFium in Delphi

To find out what changed in a PDF after it was signed, the PDFium Component for Delphi and Lazarus provides TPdf.AnalyzeSignatureRevisions, a post-signature revision change analyzer that rebuilds every incremental revision from the original file bytes, grades each later object change against that signature's DocMDP and FieldMDP rules, and reports shadow object definitions as a separate risk. The situation it targets is familiar to anyone who handles contracts: a certified form goes out, comes back with two more incremental saves, and every signature still verifies. That is expected, because a signature only covers the bytes of its own revision. The real question is whether those later saves were allowed, and a green checkmark on the signature does not answer it

Why can't the PDFium signature API show what changed after signing?

The PDFium signature API cannot show post-signature changes because it only reads the signature dictionary: /Contents, /ByteRange, /SubFilter and the DocMDP permission value. PDFium has no incremental revision graph, does not parse FieldMDP transform parameters, and offers no object-level diff between revisions, so the analyzer in FPdfPades.pas works directly on raw bytes instead. That has a practical consequence you should design around. TPdf.AnalyzeSignatureRevisions reads the bytes retained when the document was loaded, never a copy produced by SaveAs, because a rewritten file has lost the very revision structure being analyzed. If the document came from a progressive source that has not finished downloading, the report returns SourceStatus = pvssIncomplete and Status = prasIndeterminate rather than analyzing a truncated file

Rebuilding revision boundaries from startxref, xref streams and /Prev

The analyzer rebuilds revision boundaries by following every startxref back through classic xref tables, cross-reference streams, hybrid-reference /XRefStm entries and the /Prev chain, as defined for incremental updates in ISO 32000-1 §7.5.6 and §7.5.8. Each signature's covered length is the end of its second ByteRange span, and the analyzer maps that length to the revision whose xref section it falls inside. When no revision matches, the signature gets prrCoveredRevisionNotFound and an Indeterminate status. The state of every object is then replayed up to the covered revision, and each later xref entry is compared with that state. This matters more than it sounds: some writers restate the complete xref table on every incremental save, and an entry that still points at the same unchanged object is skipped instead of being reported as a modification. Without that comparison, a perfectly legal form fill would drown in hundreds of fake changes

How AnalyzeSignatureRevisions rebuilds incremental revisions from raw PDF bytes in Delphi: the signature ByteRange ends inside the covered revision, the xref Prev chain walks back through every later save, object state is replayed to the covered revision, and unchanged restated entries are skipped instead of reported as changes
A signature covers only the bytes of its own revision, so the analyzer maps the second ByteRange span to a revision and grades each later xref entry against the replayed object state

Shadow definitions are the case that deserves the most attention. An object body that appears inside a later revision's byte range but is not referenced by that revision's xref is invisible to a normal viewer, yet it is exactly the kind of staging that shadow attacks rely on: hidden content is planted before or after signing and later activated by flipping a reference. AnalyzePadesSignatureRevisionsBytes records such an object as a non-authoritative change with IsAuthoritative = False, grades it prdSuspicious regardless of the permission level, and adds prrUnreferencedObjectDefinition to the risk set. Two related risks cover other structural tricks: prrDuplicateObjectDefinition fires when one xref section lists the same object more than once, and prrSignatureObjectRedefined fires when a later revision redefines an existing signature object

A shadow object definition inside a later PDF revision byte range: the object body exists but no xref entry references it, so viewers never show it, and AnalyzeSignatureRevisions in PDFium Component records it as non-authoritative, grades it prdSuspicious and raises prrUnreferencedObjectDefinition alongside the duplicate and redefined signature risks
Hidden content is planted before or after signing and activated later by flipping a reference, which is why an unreferenced body is graded suspicious regardless of the DocMDP permission level
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;

How are DocMDP and FieldMDP enforced for each signature?

DocMDP and FieldMDP are enforced separately for each signature, at that signature's own covered revision, so a certification signature and a later approval signature in the same file can reach different verdicts about the same edit. Every later object is first classified into a TPadesRevisionChangeKind from its /Type, /Subtype and /FT entries and from the role it plays in the page, form, annotation and DSS graphs. Anything carrying /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia or /EmbeddedFile becomes prckActiveContent. The decision then follows ISO 32000-1 §12.8.2.2: with P=1 everything except cross-reference data and validation material is disallowed; P=2 permits form filling and further signatures but rejects annotation changes; P=3 also permits annotations. Page content, document structure, metadata, active content and deleted objects are disallowed under any DocMDP level, and graded prdSuspicious when the signature carries no DocMDP at all, since an approval signature forbids nothing formally but the reader no longer sees what was signed

FieldMDP, from ISO 32000-1 §12.8.2.4, narrows the form-field decision further. pfmaAll locks every field, pfmaInclude locks only the listed fields, and pfmaExclude locks everything except the listed fields. To apply Include or Exclude, the analyzer resolves each changed field to its fully qualified name through the /Parent chain and compares it with the lock list by exact match, so list terminal field names rather than expecting a parent name to cover its children. When a name cannot be resolved or the transform uses an action the parser does not recognize, the change becomes prdIndeterminate and prrFieldMdpUnresolved is raised. Per-change decisions then roll up worst-first, with Suspicious ranking above Disallowed, Disallowed above Indeterminate, and Indeterminate above Allowed, so one shadow object outweighs any number of legitimate field updates

The grading pipeline AnalyzeSignatureRevisions applies to each post-signature change in Delphi: a TPadesRevisionChangeKind from Type and Subtype entries, a DocMDP decision at the covered revision from P=1 to P=3, a FieldMDP lock check on fully qualified field names, and a worst-first roll-up from prdSuspicious down to prdAllowed
One shadow object outweighs any number of legitimate field updates because Suspicious ranks above Disallowed, Indeterminate and Allowed, while some risks are recorded beside the status without downgrading it

Why do some changes come back Indeterminate instead of safe?

Changes come back Indeterminate whenever the analyzer cannot prove a change is permitted, because in a signature check an unknown must never be reported as allowed. One common case is handled precisely instead: long-term validation adds a /DSS and rewrites the catalog, which would otherwise count as a structural change under P=1. The analyzer strips /DSS and /Extensions from the old and new catalog dictionaries and compares the rest; when nothing else differs, the rewrite is treated as a validation-material update and allowed, so B-LT and B-LTA augmentation does not break a certification signature. Other gaps are left open on purpose. Type-2 entries in a cross-reference stream point into compressed object streams, and the analyzer does not expand object streams inside this security boundary, so those changes surface as prckCompressedObject with prrCompressedObjectUnresolved, disallowed under P=1 and Indeterminate otherwise. Hard budgets of 1024 revisions, 1,000,000 object numbers and 2,000,000 reported changes produce prrResourceLimitExceeded, and a broken xref chain produces prrMalformedRevisionChain; both end as Indeterminate, never as a pass

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

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // Some risks are recorded without changing Status, so test them first
  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;

The ordering in that gate is deliberate. prrDuplicateObjectDefinition is added to the risk set without downgrading Status by itself, and a FieldMDP transform that cannot be parsed only affects the status once a form field actually changes, so a gate that looks at Status alone can miss evidence the report already contains. Keep in mind what the report does not claim either. TPadesRevisionAnalysisReport says nothing about whether the CMS signature is cryptographically valid or whether the signer certificate chains to a root you trust. Revision analysis answers the question of what happened after signing, and it belongs next to structural and trust validation, not in place of them

Writing seed values and MDP locks at signing time

The same rules can be authored when signing through TPadesSignatureFieldOptions, which is the FieldOptions member of both TPadesSignOptions and TPadesRemoteSignOptions. PDFium can create a widget but cannot write /SV, /Lock, a FieldMDP or DocMDP transform, or the catalog /Perms dictionary, so the component's own incremental PAdES writer produces these objects inside the same xref update as the signature. FieldName sets the root field name, RequiredSeedValues becomes the /Ff bits of the seed-value dictionary described in ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations and AcceptableCertificates constrain what a later signer may choose, LockAction with LockFields writes an indirect /SigFieldLock, and CertificationPermission from 1 to 3 turns the signature into a certification signature. The DocMDP and FieldMDP transforms both go into one /Reference array on the signature value, each with /Data pointing at the catalog

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // form filling and signing only
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // lock only these fields
  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;

A few details are easy to get wrong if you hand-roll this. Catalog /Perms /DocMDP must reference the signature value dictionary, not the widget annotation, and the writer keeps the signature value as its own indirect object for that reason. An existing /Perms dictionary may already hold /UR3 usage rights, so the writer copies it and inserts /DocMDP instead of replacing it, following the permissions dictionary in ISO 32000-1 §12.8.4. A document that already carries /DocMDP refuses a second certification signature with EPadesCrypto, and so do inconsistent options: an Include or Exclude lock without field names, an All lock with a field list, a legal attestation on a non-certification signature, or a period in the root field name. Remote signing adds one more rule, because the signing certificate is unknown when PreparePadesRemoteSignature runs: setting CertificateRequired there demands an explicit AcceptableCertificates list, while local signing can fall back to the resolved signer certificate

Revision analysis completes the signature toolbox rather than replacing any part of it. Start with inspecting PDF signatures and PAdES levels with the PDFium Component to read the dictionary and baseline level, look at why validators reject PAdES signatures for the structural failures that come before any revision question, and fold the verdict into a broader PDF security risk audit alongside JavaScript and embedded-file checks. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions and the incremental PAdES writer shown here ship with the PDFium Component for Delphi, C++Builder and Lazarus