Pour savoir ce qui a changé dans un PDF après sa signature, le PDFium Component pour Delphi et Lazarus fournit TPdf.AnalyzeSignatureRevisions, un analyseur de changements de révisions post-signature qui reconstruit chaque révision incrémentale depuis les octets du fichier d'origine, note chaque changement d'objet ultérieur contre les règles DocMDP et FieldMDP de cette signature, et rapporte les définitions d'objets fantômes comme un risque séparé. La situation qu'il vise est familière à quiconque manipule des contrats : un formulaire certifié part, revient avec deux sauvegardes incrémentales de plus, et chaque signature reste vérifiée. C'est attendu, parce qu'une signature ne couvre que les octets de sa propre révision. La vraie question est de savoir si ces sauvegardes ultérieures étaient permises, et une coche verte sur la signature n'y répond pas
Pourquoi l'API de signature de PDFium ne montre-t-elle pas ce qui a changé après signature ?
L'API de signature de PDFium ne peut pas montrer les changements post-signature parce qu'elle ne lit que le dictionnaire de signature : /Contents, /ByteRange, /SubFilter et la valeur de permission DocMDP. PDFium n'a pas de graphe de révisions incrémentales, n'analyse pas les paramètres de transformation FieldMDP, et n'offre aucun diff au niveau objet entre révisions, si bien que l'analyseur dans FPdfPades.pas travaille directement sur les octets bruts. Cela a une conséquence pratique à laquelle vous devriez concevoir autour. TPdf.AnalyzeSignatureRevisions lit les octets retenus au chargement du document, jamais une copie produite par SaveAs, parce qu'un fichier réécrit a perdu la structure de révisions même que l'on analyse. Si le document vient d'une source progressive qui n'a pas fini de se télécharger, le rapport renvoie SourceStatus = pvssIncomplete et Status = prasIndeterminate au lieu d'analyser un fichier tronqué
Reconstruire les frontières de révisions depuis startxref, les xref streams et /Prev
L'analyseur reconstruit les frontières de révisions en remontant chaque startxref à travers les tables xref classiques, les cross-reference streams, les entrées hybrides /XRefStm et la chaîne /Prev, comme défini pour les mises à jour incrémentales dans l'ISO 32000-1 §7.5.6 et §7.5.8. La longueur couverte de chaque signature est la fin de son deuxième segment ByteRange, et l'analyseur mappe cette longueur vers la révision dont relève sa section xref. Quand aucune révision ne correspond, la signature reçoit prrCoveredRevisionNotFound et un statut Indeterminate. L'état de chaque objet est ensuite rejoué jusqu'à la révision couverte, et chaque entrée xref ultérieure est comparée à cet état. Cela compte plus que cela ne sonne : certains écrivains réénoncent la table xref complète à chaque sauvegarde incrémentale, et une entrée qui pointe toujours vers le même objet inchangé est sautée au lieu d'être rapportée comme une modification. Sans cette comparaison, un remplissage de formulaire parfaitement légal se noierait sous des centaines de faux changements
Les définitions fantômes sont le cas qui mérite le plus d'attention. Un corps d'objet qui apparaît à l'intérieur de l'étendue d'octets d'une révision ultérieure mais n'est référencé par aucun xref de cette révision est invisible pour une visionneuse normale, et c'est exactement le genre de préparation sur laquelle s'appuient les shadow attacks : du contenu caché est planté avant ou après la signature, puis activé plus tard en retournant une référence. AnalyzePadesSignatureRevisionsBytes enregistre un tel objet comme changement non autoritaire avec IsAuthoritative = False, le note prdSuspicious quel que soit le niveau de permission, et ajoute prrUnreferencedObjectDefinition à l'ensemble de risques. Deux risques apparentés couvrent d'autres astuces structurelles : prrDuplicateObjectDefinition se déclenche quand une section xref liste le même objet plus d'une fois, et prrSignatureObjectRedefined quand une révision ultérieure redéfinit un objet de signature existant
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;
Comment DocMDP et FieldMDP sont-ils appliqués pour chaque signature ?
DocMDP et FieldMDP sont appliqués séparément pour chaque signature, à la révision couverte de cette signature même, si bien qu'une signature de certification et une signature d'approbation ultérieure dans le même fichier peuvent rendre des verdicts différents sur la même édition. Tout objet ultérieur est d'abord classé dans un TPadesRevisionChangeKind à partir de ses entrées /Type, /Subtype et /FT et du rôle qu'il joue dans les graphes de pages, de formulaires, d'annotations et de DSS. Tout ce qui porte /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia ou /EmbeddedFile devient prckActiveContent. La décision suit ensuite l'ISO 32000-1 §12.8.2.2 : avec P=1, tout sauf les données de références croisées et le matériel de validation est interdit ; P=2 permet le remplissage de formulaires et les signatures supplémentaires mais rejette les changements d'annotations ; P=3 permet aussi les annotations. Contenu de page, structure de document, métadonnées, contenu actif et objets supprimés sont interdits à tout niveau DocMDP, et notés prdSuspicious quand la signature ne porte aucun DocMDP, puisqu'une signature d'approbation n'interdit rien formellement mais que le lecteur ne voit plus ce qui a été signé
FieldMDP, issu de l'ISO 32000-1 §12.8.2.4, resserre encore la décision sur les champs de formulaire. pfmaAll verrouille chaque champ, pfmaInclude ne verrouille que les champs listés, et pfmaExclude verrouille tout sauf les champs listés. Pour appliquer Include ou Exclude, l'analyseur résout chaque champ modifié vers son nom pleinement qualifié à travers la chaîne /Parent et le confronte à la liste de verrouillage par correspondance exacte, si bien que les listes doivent nommer les champs terminaux plutôt que d'attendre d'un nom parent qu'il couvre ses enfants. Quand un nom ne peut pas être résolu ou que la transformation utilise une action que l'analyseur ne reconnaît pas, le changement devient prdIndeterminate et prrFieldMdpUnresolved est levé. Les décisions par changement se consolident ensuite du pire en premier, avec Suspicious au-dessus de Disallowed, Disallowed au-dessus d'Indeterminate, et Indeterminate au-dessus d'Allowed, si bien qu'un objet fantôme l'emporte sur n'importe quel nombre de mises à jour de champs légitimes
Pourquoi certains changements reviennent-ils Indeterminate au lieu de sûrs ?
Les changements reviennent Indeterminate chaque fois que l'analyseur ne peut pas prouver qu'un changement est permis, parce que dans un contrôle de signature un inconnu ne doit jamais être rapporté comme autorisé. Un cas courant est traité précisément au lieu de cela : la validation à long terme ajoute un /DSS et réécrit le catalogue, ce qui compterait sinon comme un changement structurel sous P=1. L'analyseur retire /DSS et /Extensions des dictionnaires de catalogue ancien et nouveau et compare le reste ; quand rien d'autre ne diffère, la réécriture est traitée comme une mise à jour de matériel de validation et autorisée, si bien que l'augmentation B-LT et B-LTA ne casse pas une signature de certification. D'autres trous restent ouverts exprès. Les entrées de Type 2 d'un cross-reference stream pointent dans des object streams compressés, et l'analyseur ne développe pas les object streams à l'intérieur de cette frontière de sécurité, si bien que ces changements remontent comme prckCompressedObject avec prrCompressedObjectUnresolved, interdits sous P=1 et Indeterminate sinon. Des budgets stricts de 1024 révisions, 1 000 000 de numéros d'objets et 2 000 000 de changements rapportés produisent prrResourceLimitExceeded, et une chaîne xref cassée produit prrMalformedRevisionChain ; les deux finissent en Indeterminate, jamais en réussite
const
StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
prrSignatureObjectRedefined, prrResourceLimitExceeded];
function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
// Certains risques sont enregistrés sans changer Status, donc testez-les d'abord
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;
L'ordre dans ce garde-fou est délibéré. prrDuplicateObjectDefinition est ajouté à l'ensemble de risques sans dégrader Status à lui seul, et une transformation FieldMDP impossible à parser n'affecte le statut qu'une fois qu'un champ de formulaire change réellement, si bien qu'un garde-fou qui ne regarde que Status peut rater des preuves que le rapport contient déjà. Gardez aussi à l'esprit ce que le rapport ne prétend pas. TPadesRevisionAnalysisReport ne dit rien de la validité cryptographique de la signature CMS ni de savoir si le certificat du signant chaîne vers une racine de confiance. L'analyse de révisions répond à la question de ce qui s'est passé après la signature, et elle se place à côté de la validation structurelle et de confiance, pas à leur place
Écrire les seed values et les verrous MDP au moment de signer
Les mêmes règles peuvent être rédigées au moment de signer via TPadesSignatureFieldOptions, qui est le membre FieldOptions de TPadesSignOptions comme de TPadesRemoteSignOptions. PDFium peut créer un widget mais ne peut pas écrire /SV, /Lock, une transformation FieldMDP ou DocMDP, ni le dictionnaire /Perms du catalogue, si bien que l'écrivain PAdES incrémental du composant produit ces objets dans la même mise à jour xref que la signature. FieldName fixe le nom du champ racine, RequiredSeedValues devient les bits /Ff du dictionnaire de seed values décrit dans l'ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations et AcceptableCertificates contraignent ce qu'un signant ultérieur peut choisir, LockAction avec LockFields écrit un /SigFieldLock indirect, et CertificationPermission de 1 à 3 transforme la signature en signature de certification. Les transformations DocMDP et FieldMDP vont toutes deux dans un unique tableau /Reference sur la valeur de signature, chacune avec /Data pointant vers le catalogue
var
Options: TPadesSignOptions;
begin
Options := TPadesSignOptions.Default;
Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
Options.Reason := 'Approved for release';
Options.FieldOptions.FieldName := 'Certification';
Options.FieldOptions.CertificationPermission := 2; // remplissage de formulaire et signature seulement
Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
Options.FieldOptions.LockAction := pfmaInclude; // verrouiller seulement ces champs
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;
Quelques détails sont faciles à rater si vous roulez cela à la main. Le /Perms /DocMDP du catalogue doit référencer le dictionnaire de valeur de signature, pas l'annotation widget, et l'écrivain garde la valeur de signature comme objet indirect propre pour cette raison. Un dictionnaire /Perms existant peut déjà porter des droits d'usage /UR3, donc l'écrivain le copie et y insère /DocMDP au lieu de le remplacer, suivant le dictionnaire de permissions de l'ISO 32000-1 §12.8.4. Un document qui porte déjà /DocMDP refuse une deuxième signature de certification avec EPadesCrypto, et il en va de même pour des options incohérentes : un verrou Include ou Exclude sans noms de champs, un verrou All avec une liste de champs, une attestation légale sur une signature non certifiante, ou un point dans le nom du champ racine. La signature distante ajoute une règle de plus, parce que le certificat signant est inconnu au moment où PreparePadesRemoteSignature tourne : positionner CertificateRequired là exige une liste AcceptableCertificates explicite, tandis que la signature locale peut retomber sur le certificat signant résolu
L'analyse de révisions complète la boîte à outils des signatures au lieu d'en remplacer une quelconque partie. Commencez par l'inspection des signatures PDF et des niveaux PAdES avec le PDFium Component pour lire le dictionnaire et le niveau de base, regardez pourquoi les validateurs rejettent les signatures PAdES pour les échecs structurels qui précèdent toute question de révision, et incorporez le verdict dans un audit plus large des risques de sécurité PDF à côté des contrôles JavaScript et fichiers embarqués. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions et l'écrivain PAdES incrémental montré ici sont livrés avec le PDFium Component pour Delphi, C++Builder et Lazarus