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 analyser 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 analyser 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 analysed. If the document came from a progressive source that has not finished downloading, the report returns SourceStatus = pvssIncomplete and Status = prasIndeterminate rather than analysing a truncated file
Rebuilding revision boundaries from startxref, xref streams and /Prev
The analyser 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 analyser 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
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
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 analyser 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 recognise, 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
Why do some changes come back Indeterminate instead of safe?
Changes come back Indeterminate whenever the analyser 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 analyser 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 analyser 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