Un contrat chiffré en AES et signé ailleurs se charge avec une valeur de signature qui décode en octets aléatoires, ou, en AES-GCM, avec un dictionnaire de signature qui refuse de se déchiffrer. En dehors du /ID du trailer et du dictionnaire de chiffrement, l'ISO 32000-2 §7.6.2 ne laisse non chiffrée que la /Contents de signature, et PDFlibPas la garde en clair à la lecture comme à l'écriture, tandis que /Reason et /M restent chiffrées
L'exemption que l'ISO 32000-2 a ajoutée aux règles de chiffrement
L'ISO 32000-2 §7.6.2 dit que le chiffrement couvre chaque chaîne et chaque flux du fichier, avec quatre exceptions :
- les valeurs de l'entrée
/IDdu trailer - les chaînes du dictionnaire
/Encrypt - les chaînes dans les flux, qui sont chiffrées en bloc
- toute chaîne hexadécimale qui est la valeur de
/Contentsdans un dictionnaire de signature
Les trois premières sont anciennes. L'ISO 32000-1 §7.6.1 n'en liste que trois, si bien qu'un déchiffreur écrit d'après le texte de 2008 n'a aucune consigne pour sauter quoi que ce soit dans un dictionnaire de signature. Les outils de signature écrivaient la valeur en clair de toute façon. La raison est mécanique — un signataire réserve un placeholder hexadécimal, écrit le fichier entier, hache tout sauf le placeholder, construit le CMS et patche son hexadécimal dans le trou. Ce condensat couvre les octets tels qu'ils sont sur le disque, ciphertext compris. Chiffrer la valeur de signature ne protègerait rien, et cela obligerait l'étape de patch à connaître la clé du fichier et à faire tenir un IV plus du padding dans un placeholder dimensionné avant que le hachage existe. Le reste du dictionnaire suit les règles normales :
| Entrée du dictionnaire de signature | Type PDF | Stockée chiffrée ? |
|---|---|---|
/Contents | chaîne hexadécimale | Non, exemptée par l'ISO 32000-2 §7.6.2 |
/Reason, /Location, /ContactInfo, /Name | chaîne de texte | Oui |
/M | chaîne de date | Oui |
/ByteRange | tableau d'entiers | Non, les entiers ne sont jamais chiffrés |
/Type, /Filter, /SubFilter | nom | Non, les noms ne sont jamais chiffrés |
Un lecteur qui rate l'exemption passe le chiffre sur du DER en clair. Le résultat dépend de l'algorithme. RC4 est un chiffrement de flux, donc la sortie garde sa longueur et devient du bruit ; le parseur CMS voit un premier octet qui n'est plus 0x30 et signale une signature mal formée. AES-CBC prend les 16 premiers octets pour le vecteur d'initialisation et le reste pour du ciphertext, ce qui finit en erreur de padding ou en un blob de charabia plus court. AES-GCM, défini pour le PDF par l'ISO/TS 32003, est le cas le plus sévère. Chaque chaîne chiffrée porte son propre nonce et son tag d'authentification, et une valeur en clair ne peut pas passer la vérification du tag. Un déchiffreur qui traite un échec de tag comme fatal pour tout le dictionnaire perd alors /Reason et /M avec la valeur de signature
Comment distinguer une /Contents de signature de toutes les autres /Contents ?
Filtrer sur le nom de clé est la première tentative évidente. Elle échoue dans les deux sens. Les dictionnaires d'annotation ont aussi une entrée /Contents — une chaîne de texte qui porte le commentaire tapé par le relecteur — et cette chaîne doit être chiffrée. La sauter et chaque post-it d'un fichier confidentiel se lit sans le mot de passe. Filtrer sur /Type /Sig casse dans l'autre sens. La table 255 de l'ISO 32000-2 rend /Type optionnel dans un dictionnaire de signature, avec Sig comme défaut, et ne l'exige que pour /DocTimeStamp, donc un producteur conforme peut s'en passer. PDFlibPas traite donc une chaîne /Contents comme valeur de signature seulement quand le même dictionnaire porte un tableau /ByteRange plus soit un /Type à Sig ou DocTimeStamp, soit pas de /Type du tout à côté d'un nom /Filter. /Filter est requis dans chaque dictionnaire de signature et /ByteRange dans chaque signature appartenant à un champ de signature ; une annotation n'a ni l'un ni l'autre. La bibliothèque applique le même test quand elle chiffre à l'enregistrement et quand elle déchiffre au chargement, si bien que la /Contents ressort d'un chargement portant les octets écrits par le producteur
Chiffrer d'abord, puis signer avec PDFlibPas
Signez en dernier. Le condensat de la signature couvre les octets stockés, donc toute opération qui réécrit le fichier après la signature casse toutes les signatures qu'il contient. Appliquer le chiffrement à un document signé rechiffre chaque chaîne et chaque flux ; retirer le chiffrement les réécrit tout aussi en profondeur. La séquence qui marche : chiffrer, enregistrer, puis signer le fichier chiffré. Le processus de signature ajoute une mise à jour incrémentale dont les nouvelles chaînes sont chiffrées avec la clé de fichier existante et dont le placeholder /Contents reste en clair. Ouvrez le fichier dans le processus de signature avec le mot de passe owner ; un mot de passe user suffit seulement quand les permissions incluent le bit 9 de la table 22 de l'ISO 32000-2, qui nomme explicitement les champs de signature. Encrypt et le processus de signature sont disponibles dans chaque édition de PDFlibPas
uses
SysUtils, PDFlibrary;
const
OwnerPwd = 'owner-secret';
UserPwd = 'reader-secret';
procedure EncryptCopy(const Source, Target: string);
var
Lib: TPDFlib;
Perms: Integer;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile(Source, '') <> 1 then
raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
// impression, champs (bit 9 couvre champs de signature), copie accessible, impression HR
Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
// Strength 4 = AES-256 révision 6 : clé de fichier directe, /ID hors de celle-ci
if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
if Lib.SaveToFile(Target) <> 1 then
raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
finally
Lib.Free;
end;
end;
procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
PFXFile, PFXPassword: string);
var
SP, R: Integer;
begin
SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
if SP = 0 then
raise Exception.CreateFmt('Input not found: %s', [Source]);
try
Lib.SetSignProcessField(SP, FieldName); // créé invisiblement si absent
Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
// marge dans le placeholder pour un horodatage de signature ajouté plus tard
Lib.SetSignProcessReserveContentsBytes(SP, 16384);
Lib.EndSignProcessToFile(SP, Target); // renvoie toujours 0
R := Lib.GetSignProcessResult(SP);
if R <> 1 then
raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
finally
Lib.ReleaseSignProcess(SP);
end;
end;
Strength 4 choisit AES-256 révision 6, et ce choix supprime deux modes d'échec. Sous RC4 et AES-128, l'Algorithme 1 (ISO 32000-1 §7.6.2) dérive une clé séparée pour chaque objet à partir de son numéro d'objet et de son numéro de génération. L'Algorithme 2 (ISO 32000-1 §7.6.3.3, étape e) nourrit aussi le premier élément /ID dans la clé de fichier. Une mise à jour de signature réécrit le dictionnaire AcroForm et le tableau /Annots de la page, si bien que chaque objet réécrit doit garder son numéro de génération d'origine, et que le trailer de la mise à jour doit porter le même premier élément /ID. Se tromper de génération et cet objet ne se déchiffre plus. Se tromper de /ID et rien ne se déchiffre. La révision 6 utilise la clé de fichier directement (ISO 32000-2 §7.6.3.3, Algorithme 1.A) et la retrouve sans consulter /ID (§7.6.4.3.3, Algorithme 2.A). PDFlibPas garde les numéros de génération sur les objets réécrits à chaque force et dérive la clé à partir du /ID tel que stocké, même quand un fichier mal formé porte /ID 7 0 R au lieu du tableau direct qu'exige la table 15 de l'ISO 32000-2 ; un /ID [] vide fait échouer la vérification du mot de passe. Le chemin mot de passe vers clé est retracé plus en détail à côté de l'ouverture de PDF chiffrés avec une clé de fichier brute
Un éditeur de texte suffit pour voir le résultat. Un dictionnaire de signature se trouve non compressé dans la mise à jour qui l'a ajouté, parce que le signataire patche /ByteRange et /Contents par décalage de fichier. Cherchez /ByteRange et vous tombez dessus. L'hexadécimal de la /Contents ouvre typiquement par 3082, l'en-tête DER de la SEQUENCE SignedData du CMS, et finit en une longue suite de zéros là où le placeholder était plus grand que la signature. La /Reason est du ciphertext qui change d'une exécution de signature à la suivante même quand le texte ne change pas, car AES tire un IV frais par chaîne. Attendu avec un qpdf récent : qpdf --password=owner-secret --show-object=14 signed.pdf imprime la /Reason déchiffrée à côté d'une /Contents inchangée. Prenez un fichier de force 4 pour cette vérification. Forme attendue dans le fichier brut, avec des décalages et des octets variables :
14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
/ByteRange [0 48211 81093 2304]
/Contents <308240A206092A864886F70D010702A0...000000000000>
/Reason <7F21C9E04B...>
/M <D35A0F98...>
...>>
endobj
Quelles signatures cassent quand on patche un horodatage ?
Toutes celles ajoutées après la signature horodatée. Un horodatage de signature PAdES est un jeton RFC 3161 stocké comme attribut non signé du signataire (RFC 5652 §5.3, unsignedAttrs), donc il vit dans le CMS, dans la /Contents. En ajouter un veut dire réécrire cet hexadécimal en place. Dans un fichier chiffré, cette réécriture ne marche que parce que la /Contents est en clair : pas de rechiffrement, pas de nouvel objet — le jeton n'a qu'à tenir dans la place réservée au moment de la signature. La signature propriétaire survit parce que son /ByteRange saute ces octets. Les signatures suivantes n'ont pas cette exemption. L'ISO 32000-2 §12.8.1 fait courir la plage d'octets de chaque signature de l'en-tête %PDF- à la fin de la mise à jour incrémentale qui l'a ajoutée. Une seconde signature, ou un horodatage de document, hache donc la /Contents de la première signature avec tout le reste. Le trou n'appartient qu'à une seule signature. Le patcher après qu'un second signataire a signé, et le condensat de la seconde signature ne correspond plus. La demande de jeton elle-même fait partie de la signature et la validation PAdES depuis Delphi ; cette section ne parle que des octets qui bougent
PDFlibPas vérifie le chevauchement avant de hacher quoi que ce soit. Le processus d'horodatage de signature lit la /ByteRange de chaque autre signature du document et compare chaque paire d'étendues avec le trou /Contents de la cible. Tout chevauchement arrête la préparation avec le résultat de processus 17, et aucun fichier de sortie n'est écrit. Le processus appartient à l'édition Enterprise, que vous instanciez comme TPDFlibEnterprise depuis l'unité PDFlibEnterprise ; les autres éditions gardent les méthodes, qui renvoient 0 et mettent LastErrorCode à 900. Surveillez le nombre 17 dans vos journaux. Venu de GetPAdESSignatureTimeStampProcessResult, il signifie qu'une signature ultérieure couvre celle-ci ; venu de GetSignProcessResult, qu'un niveau de certification ou un verrou de champ interdit la signature. Mappez les deux séparément. L'aide ci-dessous utilise SignField et OwnerPwd du listing précédent
uses
SysUtils, PDFlibrary, PDFlibEnterprise;
const
STS_COVERED_BY_LATER_SIGNATURE = 17;
function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
FieldName: string): Integer;
var
STS: Integer;
begin
Result := 0;
STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
if STS = 0 then
Exit;
try
Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
begin
Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
if Result = 1 then
Result := -1; // valeur hachée, échange TSA échoué : voir GetTimeStampError
Exit;
end;
Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
finally
Lib.ReleasePAdESSignatureTimeStampProcess(STS);
end;
end;
procedure SignAndStampInOrder(const EncryptedFile: string);
var
Lib: TPDFlibEnterprise;
R: Integer;
begin
Lib := TPDFlibEnterprise.Create;
try
Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');
SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
if R <> 1 then
raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);
SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
if R <> 1 then
raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);
// Attendu : 17. La plage d'octets d'Approver couvre la /Contents de Author
// dans step2.pdf, donc le processus refuse et restamped.pdf n'est jamais écrit
R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
if R <> STS_COVERED_BY_LATER_SIGNATURE then
raise Exception.CreateFmt('Unexpected result %d', [R]);
finally
Lib.Free;
end;
end;
Horodatez chaque signature avant que le signataire suivant ne touche le fichier. Cela coûte un aller-retour TSA par signataire et donne à chaque signature sa propre heure de confiance. Un fichier qui arrive avec plusieurs signatures déjà en place est un autre cas : les antérieures ne peuvent plus prendre d'horodatage de signature. Ajoutez plutôt un horodatage de document — une signature /Type /DocTimeStamp avec /SubFilter /ETSI.RFC3161 dans une nouvelle révision, dont la plage d'octets couvre toutes les signatures antérieures et prouve qu'elles existaient à ce moment. ApplyPAdESLongTermArchiveValidation en ajoute un après avoir attaché le matériel de validation. Réservez la place d'un horodatage de signature au moment de signer, via SetSignProcessReserveContentsBytes comme dans le premier listing. Un jeton qui embarque la chaîne de certificats du TSA atteint facilement plusieurs kilooctets, et un placeholder sans cette marge fait échouer l'augmentation ultérieure avec le résultat 13
Déchiffrer un fichier signé et ce que hache un vérificateur
Les plages signées d'un fichier chiffré sont du ciphertext. Un vérificateur compare un condensat du ciphertext stocké avec le condensat dans le CMS : la /ByteRange est des entiers, la /Contents est en clair, et la vérification d'intégrité n'a besoin d'aucune clé — seuls le nom du champ et la /Reason en ont besoin. Le même fait explique pourquoi déchiffrer un fichier signé et l'enregistrer en clair invalide chaque signature qu'il contient, alors qu'aucun pixel visible n'a changé. Gardez l'original chiffré et signé comme copie de référence. Les flux de références croisées ne sont jamais chiffrés non plus (ISO 32000-2 §7.5.8.2), si bien qu'une mise à jour écrite avec un flux xref garde ses décalages lisibles avant que toute clé soit dérivée. La disposition est décrite dans les flux d'objets et les flux de références croisées en Delphi. Quand une plage signée est trop grande pour être lue en mémoire pour comparaison, PDFlibPas signale la vérification d'intégrité comme inconnue plutôt qu'invalidée, car des octets jamais comparés ne se sont pas avérés différents
La valeur de signature d'un fichier chiffré reste réinscriptible après la signature jusqu'à ce qu'une autre signature ou un horodatage de document la couvre. Mettez l'étape d'horodatage dans cette fenêtre, et la séquence chiffrer-puis-signer du premier listing se transmet aux fichiers multi-signataires de PDF Library pour Delphi sans changements