Article Technique

Why Validators Reject PAdES Signatures: PDFium in Delphi

Dans presque tous les cas que nous avons débogués avec le composant PDFium Component dans Delphi, un validateur rejette une signature PAdES pour l'une des trois raisons suivantes : le tableau /ByteRange contient toujours ses espaces réservés de valeur zéro, l'heure de signature /M n'est pas une chaîne de date PDF bien formée, ou une mise à jour incrémentielle a supprimé l'entrée /Encrypt d'un document chiffré. Ces trois défauts produisent des fichiers qui s'ouvrent et s'affichent correctement, mais échouent dès qu'un validateur conforme analyse le dictionnaire de signature

Le scénario à l'origine de cet article est particulièrement précis. Vous signez un contrat en suivant le processus décrit dans l'article sur la signature PAdES B-B, votre propre code de vérification indique que la signature est présente et structurellement correcte, tous les tests locaux sont au vert — puis la contrepartie téléverse le fichier sur sa plateforme de validation et obtient une croix rouge. Rien de cette défaillance n'est visible dans un visualiseur, car aucun de ces trois défauts n'affecte le contenu de la page. Ils résident entièrement dans le dictionnaire de signature et la bande-annonce (trailer) du fichier, là où les validateurs regardent et où les visualiseurs ne regardent généralement pas

Pourquoi la plage d'octets (ByteRange) de ma signature PDF est-elle non valide ?

Un rejet de ByteRange signifie presque toujours que les quatre champs n'ont jamais été renseignés, et non que les plages sont légèrement incorrectes. Le tableau /ByteRange [A B C D] déclare deux tranches : de l'octet A à A+B et de l'octet C à C+D. La norme EN 319 142-1 §6.3 (exigence k) exige qu'ensemble elles couvrent l'intégralité du fichier, à l'exception de la chaîne /Contents encodée en hexadécimal. En chiffres : A vaut 0, C >= A+B, C+D est égal à la longueur du fichier, et l'espace entre B et C contient précisément la chaîne hexadécimale <...> — soit deux octets de crochets plus deux caractères hexadécimaux par octet de données CMS. Un validateur qui lit [0 0 0 0] en conclut que la signature ne couvre rien et la rejette, quelle que soit la validité de la structure CMS dans /Contents

Il convient de comprendre le mécanisme de cette défaillance car le même modèle existe dans tous les moteurs de signature. Un signataire ne peut connaître les décalages finaux avant la mise en page du fichier, le générateur émet donc le tableau avec des espaces réservés de valeur zéro de largeur fixe et les remplace après la mise en page. Dans les versions de PDFium Component antérieures à la version 2.14.1, ce remplacement recherchait le motif de l'espace réservé à partir de la position de /Contents. Toutefois, /ByteRange se situe avant /Contents dans le dictionnaire. La recherche n'aboutissait pas et les trois remplacements échouaient silencieusement. Le condensé CMS étant calculé sur les bonnes plages, la cryptographie était correcte ; la déclaration de ces plages restait à zéro, ce qui faisait que tout validateur conforme refusait le fichier. Les horodatages de documents PAdES B-LTA, qui utilisent la même structure de dictionnaire, échouaient de la même façon — ce qui s'avère important si vous vous appuyez sur les signatures à long terme B-LT et B-LTA. La version 2.14.1 effectue le remplacement à partir du début de l'objet de signature, et la correction est couverte par un test de régression qui analyse le fichier produit et valide les calculs ci-dessous

function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
  Raw: TBytes;
  Text: AnsiString;
  P, N: Integer;
  F: array[0..3] of Int64;
begin
  Raw := TFile.ReadAllBytes(FileName);
  SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
  P := Pos('/ByteRange', Text);          // first signature only
  Result := P > 0;
  if not Result then Exit;
  Inc(P, Length('/ByteRange'));
  for N := 0 to 3 do
  begin
    while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
    F[N] := 0;
    while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
    begin
      F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
      Inc(P);
    end;
  end;
  // EN 319 142-1 §6.3 req k: spans cover everything except /Contents
  Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
            (F[2] + F[3] = Int64(Length(Raw)));
end;

Vingt lignes de code RTL simple, sans appel de bibliothèque, suffisent à détecter toute cette classe de défaillances lors de la production. S'il y a une assertion à retenir de cet article, c'est celle-ci : analysez votre propre sortie signée et vérifiez les trois égalités avant que le fichier ne soit transmis

Pourquoi les validateurs signalent-ils l'heure de signature /M comme malformée ?

Les validateurs stricts rejettent toute heure de signature qui n'est pas une chaîne de date PDF complète, et les deux éléments les plus souvent manquants sont le préfixe D: et le marqueur de décalage UTC. La norme ISO 32000-1 §7.9.4 définit le format de date sous la forme D:YYYYMMDDHHmmSS suivi d'un décalage — Z pour UTC, ou une relation signée +HH'mm' par rapport à celui-ci. Un simple 20260709143000 est interprété comme une date par un lecteur tolérant, mais un validateur appliquant la grammaire littéralement détecte une chaîne malformée dans un champ au format obligatoire et rejette la signature. Les versions de PDFium Component antérieures à la version 2.14.4 écrivaient l'entrée /M de TPdf.SignPades et SignPadesBytes sous cette forme brute ; depuis la version 2.14.4, l'entrée comporte le préfixe D: et le marqueur Z, de sorte que l'heure de signature déclarée s'écrit D:20260709143000Z

Deux remarques pratiques s'appliquent à ce champ. Premièrement, écrivez en UTC et indiquez-le : un horodatage sans marqueur de décalage oblige le validateur à deviner la relation avec le fuseau horaire, et la section 7.9.4 traite le décalage comme faisant partie du format et non comme une option facultative. Deuxièmement, rappelez-vous ce qu'est /M — l'heure déclarée par le signataire, une simple prétention et non une preuve. Un validateur vérifie son format ici, pas sa véracité ; une heure vérifiable provient d'un horodatage RFC 3161 à partir du niveau PAdES B-T. Formater correctement un champ et s'y fier sont deux décisions distinctes, et les validateurs n'imposent que la première

Pourquoi SignPades lève-t-il une exception EPadesCrypto sur un PDF chiffré ?

Le composant PDFium Component refuse de signer un document chiffré car l'alternative consisterait à produire un fichier que les lecteurs conformes détruiront à l'ouverture. Une signature PAdES est ajoutée sous forme de mise à jour incrémentielle, et la norme ISO 32000-1 §7.5.6 exige que la nouvelle bande-annonce (trailer) d'une section de mise à jour contienne chaque entrée de la bande-annonce précédente, à l'exception de /Prev — ce qui inclut /Encrypt dans un document chiffré. Omettez-la et la dernière bande-annonce déclarera le fichier comme non chiffré. Un lecteur conforme analysera alors le corps chiffré comme du texte brut et obtiendra des données corrompues. De plus, les flux et les chaînes que le signataire ajoute devraient eux-mêmes être chiffrés avec la clé du document, ce qu'un injecteur de signature en texte brut ne peut faire. Il n'existe aucun moyen d'ajouter une signature en texte brut valide à un fichier chiffré. C'est pourquoi, depuis la version 2.14.2, les fonctions TPdf.SignPades, SignPadesBytes et InjectPadesDssMarkers lèvent l'exception EPadesCrypto au lieu d'émettre un résultat corrompu ou invérifiable

try
  if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
    Writeln('Signing reported failure');
except
  on E: EPadesCrypto do
  begin
    // e.g. 'SignPadesBytes: the source document is encrypted;
    //       remove encryption before signing'
    Writeln('Cannot sign: ', E.Message);
  end;
end;

L'exception est le résultat attendu, concevez donc le flux de travail en conséquence au lieu de la capturer pour réessayer. Déchiffrez d'abord le fichier avec l'autorité du propriétaire, signez la copie en texte brut et, si le canal de distribution exige un chiffrement, acceptez le fait que chiffrer puis signer produit un résultat différent de signer puis chiffrer, avec des processus de validation distincts. Une signature calculée sur des octets en texte brut ne peut survivre au chiffrement de ces octets, les seules options viables sont donc un fichier en texte brut signé ou une décision de conception documentée à côté du code

Comment deux bugs peuvent se valider mutuellement

Le défaut de ByteRange est resté caché car notre propre validateur était erroné de manière complémentaire, ce qui constitue la leçon la plus importante de cet article. La vérification de couverture dans ValidatePadesCompliance exigeait que la seconde tranche commence exactement à A+B — soit un intervalle nul — ce qui classait incorrectement la structure standard où l'intervalle contient la chaîne hexadécimale /Contents. Ainsi, le validateur interne rejetait précisément la structure conforme et le générateur interne n'en produisait jamais, de sorte que les processus de bout en bout de signature puis validation restaient au vert. Chaque bug privait la suite de tests du contre-exemple qui aurait révélé l'autre. La version 2.14.1 a corrigé les deux aspects dans la même version : le générateur renseigne les quatre champs et le validateur accepte C >= A+B avec C+D égal à la longueur du fichier

La correction méthodologique réside dans la validation croisée par rapport à une implémentation que vous n'avez pas écrite. Un générateur et un validateur qui partagent une base de code, un auteur ou même simplement une conception du format peuvent s'accorder indéfiniment sur une incompréhension commune ; un validateur indépendant rompt cette symétrie. Soumettez votre sortie signée à au moins un outil de vérification de conformité externe avant une version, et conservez une vérification structurelle automatique dans le processus de construction — TPdf.ValidatePades, décrit dans l'article sur l'inspection des signatures, signale le défaut de couverture comme une anomalie nommée

var
  R: TPadesValidationResult;
begin
  Pdf.FileName := 'contract-signed.pdf';
  Pdf.Active := True;
  R := Pdf.ValidatePades;
  if ppeiByteRangeNotCoveringFile in R.Issues then
    Writeln('ByteRange does not cover the file');
  if not R.IsCompliant then
    Writeln('Structural issues present: do not ship this file');
end;

Une liste de contrôle en cas de rejet de la sortie signée

Lorsqu'une plateforme rejette votre signature PAdES, vérifiez les causes structurelles simples avant de suspecter les certificats ou les chaînes de confiance. Lisez le tableau /ByteRange et vérifiez les trois égalités : premier champ à zéro, seconde tranche commençant au niveau ou après la fin de la première, seconde tranche se terminant exactement à la fin du fichier. Lisez l'entrée /M et vérifiez qu'il s'agit d'une chaîne de date complète selon la section 7.9.4, avec le préfixe D: et un marqueur de décalage. Confirmez que le document source n'était pas chiffré lors de l'ajout de la signature — et si votre moteur l'a signé sans broncher, considérez ce silence comme un bug de ce dernier. Ces trois vérifications s'exécutent sur les octets bruts en quelques millisecondes et expliquent le rejet bien plus souvent que toute cause cryptographique

Les appels de signature, d'inspection et de validation utilisés ici — SignPades, ValidatePades et les vérifications de conformité PAdES avec leurs calculs de ByteRange, formatage de date et filtrage de chiffrement corrigés — sont intégrés au composant PDFium Component pour Delphi, C++Builder et Lazarus