Un contrat est signé, puis gagne une mise à jour de plus, et un script d'audit fond cette mise à jour dans la révision signée ou fait passer la page réécrite pour du matériel de validation. Ni l'un ni l'autre n'arrive dès qu'une révision se termine là où startxref nomme une section que la chaîne /Prev a atteinte et qu'un objet réécrit garde sa classe antérieure — les deux règles derrière la carte des révisions de PDFlibPas
Pourquoi %%EOF est le mauvais endroit pour couper un fichier
Chaque mise à jour incrémentale se termine par son propre marqueur de fin de fichier (ISO 32000-1 §7.5.6), donc couper le fichier à chaque %%EOF a l'air de la manière évidente de trouver les révisions. Le marqueur ne fait que cinq octets, pourtant, et rien n'empêche ces octets de surgir dans un flux de contenu, un fichier embarqué ou un programme de police. Les fichiers linéarisés ajoutent un second problème : l'Annexe F (§F.3.4) laisse le trailer de la première page se terminer par son propre startxref et %%EOF près du début du fichier, bien avant la table de références croisées principale. Couper à chaque marqueur invente des révisions qu'aucune section de références croisées n'appuie. Deviner quels marqueurs sont faux est pire — cette devinette est justement ce que le faussaire peut façonner. La carte des révisions de PDFlibPas marche dans l'autre sens. Elle suit la chaîne /Prev, enregistre le décalage d'octet de chaque section de références croisées que la chaîne a réellement lues, et ce n'est qu'ensuite qu'elle balaie à la recherche de séquences startxref N … %%EOF, ne gardant une frontière que si N fait partie de ces décalages enregistrés. Un marqueur égaré dans un flux aurait besoin d'un startxref devant lui nommant une vraie section avant de pouvoir compter pour quoi que ce soit
Les octets entre ces trois jetons sont moins propres que le §7.5.5 le laisse croire. La norme dessine startxref, le décalage et le marqueur sur des lignes consécutives, pourtant les marqueurs de fin de ligne et les autres caractères d'espacement du §7.2.2 (table 1) peuvent se répéter, et aucune règle ne plafonne leur nombre. Rater un marqueur bourré et deux révisions fusionnent. La signée paraît maintenant se terminer après des octets que personne n'a signés. La carte des révisions de PDFlibPas suit la séquence du mot-clé à l'opérande au marqueur, quelle que soit la quantité d'espacement entre eux et où que ce bourrage tombe dans le fichier, et elle compte chaque mot-clé une fois. Le mot-clé trailer a une articulation lâche similaire : << est un délimiteur (§7.2.2, table 2) et le §7.5.5 n'exige jamais de saut de ligne après trailer, si bien que trailer<< sur une ligne est une syntaxe conforme. Lu ligne par ligne, cette ligne ressemble à un en-tête de sous-section de références croisées — et la chaîne stoppe à moitié
Parcourir la carte des révisions avec PDFlibPas
Les accesseurs de révision sont déclarés sur TPDFlib et implémentés dans l'édition Small Business : créez un TPDFlibSmallBusiness depuis l'unité PDFlibSmallBusiness (Enterprise en descend et porte les mêmes appels). Sur un TPDFlib simple, ils renvoient leur valeur d'échec (0, 7 ou -1) et mettent LastErrorCode à PDFLIB_ERROR_SMALLBUSINESS_REQUIRED. Les révisions sont numérotées à partir de 0, le document d'origine. Chaque appel prend un nom de fichier et un mot de passe et construit la carte depuis les octets sur disque, si bien que les réponses décrivent le fichier tel que stocké, jamais un document que vous auriez chargé et édité. GetDocumentRevisionKind signale le changement le plus profond d'une révision. Une mise à jour qui ajoute une réponse OCSP et réécrit aussi une page est signalée comme la réécriture de page :
| Genre | Signification | Source typique |
|---|---|---|
| 0 | Housekeeping seul : des objets qui ne changent rien de visible pour le lecteur | Flux de références croisées, un nouveau conteneur de flux d'objets |
| 1 | Matériel de validation | Document security store, VRI, certificats, CRL, réponses OCSP |
| 2 | Horodatage de document | Une signature /DocTimeStamp et le champ qui la porte |
| 3 | Signature | Un champ de signature existant recevant sa valeur |
| 4 | Champ de formulaire | Une valeur de champ ou son apparence |
| 5 | Annotation | Toute annotation autre qu'un widget |
| 6 | Contenu du document | Tout ce qui peut changer ce que le document montre |
| 7 | Indéterminable | Quelque chose dans la révision n'a pas pu être lu ; à traiter comme non sûr |
uses
System.SysUtils, PDFlibSmallBusiness;
procedure ListRevisions(const FileName, Password: WideString);
const
KindNames: array[0..7] of string = ('housekeeping', 'validation material',
'document time stamp', 'signature', 'form field', 'annotation',
'content', 'undetermined');
var
Pdf: TPDFlibSmallBusiness;
Count, I, J, Kind, Changes: Integer;
begin
Pdf := TPDFlibSmallBusiness.Create;
try
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
if Count = 0 then
raise Exception.Create('the cross-reference chain could not be followed');
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Writeln('WARNING: history incomplete, every figure below is a lower bound');
for I := 0 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind < 0) or (Kind > 7) then
Kind := 7;
Writeln(Format('revision %d: bytes %d..%d, %s', [I,
Pdf.GetDocumentRevisionStart(FileName, Password, I),
Pdf.GetDocumentRevisionEnd(FileName, Password, I),
KindNames[Kind]]));
// une ligne 'objnum,change,class,detail' par objet
Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
if Changes <> 0 then
try
for J := 1 to Pdf.GetStringListCount(Changes) do // les éléments comptent à partir de 1
Writeln(' ' + Pdf.GetStringListItem(Changes, J));
finally
Pdf.ReleaseStringList(Changes);
end;
end;
finally
Pdf.Free;
end;
end;
GetDocumentRevisionsComplete est l'appel à lire avant de se fier à tous les autres. Il renvoie 1 seulement quand la chaîne a été suivie jusqu'à la section d'origine ; un /Prev cassé ou cyclique, ou une chaîne plus profonde que 256 maillons, laisse les mises à jour les plus anciennes invisibles, et chaque compte et chaque indice devient alors une borne inférieure. Une profondeur de 256 laisse de la place pour l'historique enregistrer-et-signer des fichiers réels, et une chaîne qui se pointe elle-même se termine presque aussitôt. Surveillez l'indexation dans la boucle. GetStringListItem compte à partir de 1 tandis que les révisions comptent à partir de 0, et démarrer la boucle interne à 0 perd le premier objet changé de chaque révision sans aucune erreur
Comment une mise à jour forgée se fait passer pour du matériel de validation
La validation à long terme PAdES accole légitimement des mises à jour après la dernière signature : un document security store (/Type /DSS), des dictionnaires /VRI par signature, des flux de certificats et de révocation, des horodatages de document. Les niveaux de certification les laissent passer, ce qui fait du matériel de validation l'étiquette la plus précieuse qu'une mise à jour puisse porter. C'est aussi la moins chère à truquer. /Type est ce que la mise à jour a choisi d'écrire. Un viewer qui dessine une page ne regarde jamais le /Type d'un flux de contenu, d'un dictionnaire de ressources ou d'une police, si bien qu'une mise à jour peut réécrire le flux de contenu de la page 3, ajouter /Type /DSS à son dictionnaire, et chaque moteur de rendu peindra quand même le nouveau texte tandis qu'un classificateur qui croit /Type voit du housekeeping. /VRI, /Sig et /DocTimeStamp marchent de la même façon
La carte des révisions de PDFlibPas ignore l'étiquette qu'une mise à jour écrit sur un objet qu'elle réécrit. Un objet réécrit compte comme matériel de validation, horodatage ou signature seulement quand la version antérieure de ce même objet en était déjà un. Les flux de certificats, de CRL et de réponses OCSP ne portent aucun type propre, si bien qu'ils sont reconnus par le document security store qui pointe vers eux — et seul un store écrit dans la révision jugée ou dans une plus ancienne compte. Un store ajouté plus tard ne peut pas revenir en arrière et garantir un objet que la révision signée a écrit. Les changements de la structure du document comptent comme contenu dès qu'ils changent ce qu'un lecteur résoudrait :
- remplacer un flux d'objets existant, parce que chaque objet compressé dedans se résout désormais vers le remplacement (un conteneur tout neuf reste du housekeeping, puisque chaque membre reçoit sa propre entrée et est jugé là)
- ajouter un catalogue ou une page
- un trailer dont le
/Rootnomme un autre catalogue, même quand la mise à jour ne redéfinit rien de ce que la signature couvrait, puisque échanger la racine remplace tout le document sans toucher un seul objet signé - un flux réécrit dont le dictionnaire est identique octet pour octet à l'ancien : un
/Lengthindirect ou un corps de remplacement bourré à la même taille garde le dictionnaire inchangé pendant que le dessin change, donc seuls les objets sans corps de flux sont traités comme inchangés sur cette preuve
Les membres de flux d'objets n'ont pas d'en-tête à retirer
Depuis PDF 1.5, une mise à jour peut stocker des objets réécrits dans un flux d'objets, et le §7.5.7 est sec sur le format : seules les valeurs d'objets sont stockées, et les mots-clés obj et endobj ne doivent pas être utilisés. Du code qui lit un objet à son emplacement et retire l'en-tête en cherchant la chaîne obj ne trouve donc rien au début d'un membre et continue de chercher dans son corps. Donnez à un dictionnaire de page réécrit après signature une chaîne à l'air innocent comme (obj<</Type/DSS>>) et cette recherche stoppe dans la chaîne. Le décodeur reçoit <</Type/DSS>> — un dictionnaire de store que la révision n'a jamais écrit. La carte des révisions de PDFlibPas parse N G obj depuis le début d'un objet non compressé, vérifie que les numéros nomment l'objet attendu, et traite un en-tête manquant ou inadapté comme illisible au lieu de chercher plus loin
Les flux de références croisées portent leur propre ambiguïté au niveau des champs. Sous le §7.5.8.3 (table 18), seule une entrée de type 1 porte un décalage d'octet. Le second champ d'une entrée de type 2 est le numéro d'objets du flux d'objets contenant et le troisième est l'indice dedans, tandis que le second champ d'une entrée de type 0 est le prochain numéro d'objets libre. Lire ce numéro d'objets comme un décalage et une entrée compressée atterrit dans la révision qui couvre ces petites positions d'octets — presque toujours le document d'origine, ce qui cache la réécriture dans les octets signés. La carte des révisions place chaque entrée selon la section de références croisées qui l'a déclarée. La même clause dit qu'un type d'entrée autre que 0, 1 ou 2 est une référence à l'objet nul, si bien que ces entrées n'ajoutent ni ne libèrent rien et restent hors de la carte
Quand un audit de signature doit-il refuser de répondre ?
Chaque fois que le fichier retient une partie de sa propre histoire. La carte des révisions le signale de trois façons : GetDocumentRevisionsComplete renvoie 0, GetDocumentRevisionKind renvoie 7 pour une révision après la signature, ou GetSignatureRevisionIndex renvoie -1. Cette dernière valeur couvre une ByteRange qui ne se termine pas sur une frontière de révision — des octets se trouvent dans la plage que la signature prétend couvrir — et de même un champ qui n'existe pas, ne porte aucune valeur de signature ou se trouve dans un fichier illisible. Envoyez les trois en revue manuelle avec l'indice de révision et les lignes GetDocumentRevisionChanges jointes, pour que celui qui ouvre le fichier commence à partir des numéros d'objets
// Vérifie les mises à jour après une signature contre une politique de type DocMDP.
// Les genres sont ordonnés par sévérité, donc chaque niveau de politique est un plafond :
// P 1 tolère housekeeping, matériel de validation et horodatages (0..2),
// P 2 ajoute signature et remplissage de formulaires (..4), P 3 ajoute les annotations (..5).
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
Ceiling: array[1..3] of Integer = (2, 4, 5);
var
Signed, Count, I, Kind: Integer;
begin
Result := False;
if (Level < 1) or (Level > 3) then
Exit;
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Exit; // une partie de l'histoire n'a jamais été vue
Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
if Signed < 0 then
Exit; // hors frontière de révision, pas de champ signé
// de ce nom, ou fichier illisible
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
for I := Signed + 1 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind = 7) or (Kind > Ceiling[Level]) then
Exit; // 7 dépasse tout plafond ; indiqué pour le lecteur
end;
Result := True;
end;
Les plafonds suivent l'ISO 32000-2 §12.8.2.2 (table 257) : P 1 ne permet aucun changement, 2 permet le remplissage de formulaires et la signature, 3 ajoute les annotations, et les mises à jour incrémentales qui n'ajoutent que des données DSS ou des horodatages de document ne comptent pas comme changements à aucun niveau. L'ISO 32000-1 §12.8.2.2 définit les trois mêmes valeurs sans cette exemption. Écrivez la boucle vous-même quand la politique est la vôtre plutôt que celle du certificateur, par exemple une règle d'entrée qui refuse les contrats annotés quel que soit le P du fichier. Quand la politique doit venir du fichier, GetSignatureModificationVerdict sur la même classe lit le niveau de certification, applique les verrous par champ décrits dans la détection des modifications des champs de formulaire verrouillés par FieldMDP, et renvoie 5 pour le cas hors frontière. Les deux répondent à une question différente de la classification des changements contre une copie du document que vous avez gardée : la carte des révisions ne demande rien de plus que le fichier devant elle
Accoler suit la même chaîne. Le trailer d'une mise à jour incrémentale doit porter une entrée /Prev donnant l'emplacement de la section de références croisées précédente (§7.5.6), et un document qui ne pouvait s'ouvrir que par la reconstruction de la table de références croisées depuis un balayage complet n'a pas de tel emplacement à donner. AppendToFile renvoie 0 pour lui et laisse le fichier cible intact. Enregistrez-le en entier et signez la copie propre — une réécriture complète ne transporte pas les signatures existantes
Pour une archive de contrats signés, exécutez UpdatesWithinPolicy une fois par champ de signature avant que quoi que ce soit de nouveau ne soit accolé. Un fichier dont la révision ultérieure se lit comme genre 7 doit d'abord atteindre un humain, avant toute étape de signature ou de validation à long terme PDFlibPas qui enterrerait la révision illisible sous une autre mise à jour