PDFium Component construit le bloc CMS signedAttrs via TDerWriter.SetOfSorted, car la clause X.690 11.6 exige que les membres d'un SET OF soient émis dans l'ordre croissant de leur propre encodage DER. Assemblez-les dans l'ordre de construction à la place, et vous obtenez des signatures que votre propre vérificateur accepte et que rejette chaque validateur PAdES tiers
Cette dernière phrase explique à elle seule pourquoi ce défaut a survécu à une suite de régression complète. Le chemin de signature était couvert, le chemin de vérification était couvert, l'aller-retour passait au vert sur toutes les plateformes, et le résultat n'était pourtant pas du DER valide. Un test qui signe avec votre writer et vérifie avec votre reader ne peut pas détecter un écart aux règles d'encodage, car les deux moitiés partagent ce même écart
Ce qu'exige réellement la clause X.690 11.6
Les membres d'une valeur SET OF doivent être triés, et la clé de tri est l'encodage DER complet de chaque membre, comparé comme une chaîne d'octets non signée, l'encodage le plus court venant en premier lorsque l'un est un préfixe de l'autre. C'est une restriction canonique et non une suggestion : le DER n'admet qu'un seul encodage pour une valeur donnée, donc un SET OF non trié n'est pas un encodage différemment ordonné de la même valeur, ce n'est pas du DER du tout. Comme les tags ASN.1 viennent en premier dans un TLV, la comparaison se résout généralement dans les deux premiers octets, et puisque chaque membre d'une liste d'attributs CMS commence par 30, le tag SEQUENCE, l'égalité se départage sur l'octet de longueur. L'ordre requis ressemble donc presque à un tri des attributs par taille, ce qui vaut le coup de savoir quand on fixe un dump hexadécimal en se demandant pourquoi l'ordre semble arbitraire
Pourquoi l'ensemble d'attributs minimal est-il déjà dans le désordre ?
Parce que l'ordre de construction naturel et l'ordre encodé divergent dès la toute première signature que vous produisez, sans qu'aucun attribut optionnel ne soit en jeu. BuildSignedAttrs dans FPdfCms.pas ajoute content-type, puis message-digest, puis signing-certificate-v2, puis cms-algorithm-protection. Avec des digests SHA-256 et un algorithme de signature RSA, les quatre TLV d'attributs commencent par ces octets
// Ordre de construction produit par BuildSignedAttrs
// content-type 30 18 (24 octets de contenu)
// message-digest 30 2F (47)
// signing-certificate-v2 30 37 (55)
// cms-algorithm-protection 30 2D (45)
//
// Ordre DER exigé par X.690 11.6
// 30 18 content-type
// 30 2D cms-algorithm-protection
// 30 2F message-digest
// 30 37 signing-certificate-v2
Deux des quatre attributs bougent. cms-algorithm-protection à 30 2D doit précéder signing-certificate-v2 à 30 37, et doit aussi précéder message-digest à 30 2F. Le plus petit ensemble d'attributs PAdES B-B conforme que PDFium Component puisse émettre était donc déjà mal ordonné sous l'ancien code, avant même qu'on ajoute signing-time, commitment-type-indication ou un signature-policy-identifier. Il n'y a jamais eu de configuration dans laquelle le bug ne se déclenchait pas
Pourquoi nos propres tests passent-ils quand chaque validateur externe échoue ?
Parce que les deux familles de vérificateurs calculent le message digest sur des octets différents. La clause 5.4 de la RFC 5652 dit que l'entrée de l'opération de signature est l'encodage DER de la valeur SignedAttributes, le tag IMPLICIT [0] étant remplacé par le tag SET 31, et c'est la formulation l'encodage DER, pas les octets que vous avez reçus, qui décide de tout. Le vérificateur de la plateforme Windows, atteint via CryptVerifyDetachedMessageSignature, prend les octets reçus, échange le tag de tête et les hache tels qu'ils sont arrivés, donc l'ordre n'y entre jamais et un SET OF mal ordonné produit exactement le digest que le signataire a calculé. OpenSSL et BouncyCastle, sur lesquels sont construits les validateurs européens largement déployés, analysent l'ensemble d'attributs en structures puis le ré-encodent avant de hacher. Le ré-encodage applique le tri, le digest ne correspond plus à celui qui a été signé, et la signature est signalée comme cassée plutôt que mal encodée, ce qui vous envoie fouiller les clés et les chaînes de certificats pour un défaut qui vit dans une fonction de comparaison
Généralisez au-delà de ce bug précis, car c'est là que ça coûte cher. Un aller-retour de même origine est immunisé contre tout écart à une spécification d'interopérabilité que les deux moitiés de votre code partagent ; il prouve que votre writer et votre reader s'accordent entre eux, ce qui n'est pas la propriété qui intéresse qui que ce soit. Pour tout ce qui quitte le processus comme format filaire, la seule régression qui compte est celle qui épingle les octets contre la spécification ou fait tourner une implémentation étrangère sur votre sortie. Le backend de vérification CMS OpenSSL gagne sa place ici pour une seconde raison au-delà de la portabilité : c'est un lecteur réellement indépendant de votre propre DER
Le correctif, et où il existait déjà
Le writer DER avait la variante triée depuis le début. TDerWriter dans FPdfAsn1.pas expose à la fois SetOf, qui enveloppe un buffer de contenu pré-concaténé dans un tag SET et préserve l'ordre qu'on lui a donné, et SetOfSorted, qui prend les membres individuellement comme TDerByteArrays et les trie par fusion selon leurs encodages avant d'envelopper. Le code MAC PDF dans FPdfMac.pas construisait déjà son ensemble d'attributs authentifiés avec SetOfSorted ; seul le signataire CMS ne le faisait pas. Garder les membres sous forme de tableau jusqu'à l'appel d'enveloppement est la moitié structurelle du correctif, car une fois les attributs concaténés dans un seul buffer, l'information d'ordre est perdue et aucun soin en aval ne peut la restaurer. Une API de writer qui n'offre que SetOf(Content: TBytes) pour un SET OF est une API qui invite ce bug ; la surcharge qui prend un tableau est ce qui rend la chose correcte exprimable
// Avant : les membres gardent l'ordre dans lequel AddAttr les a ajoutés
Result:= W.SetOf(ConcatMany(AttrList));
// Après : l'ordre X.690 11.6 appliqué au point d'encodage
Result:= W.SetOfSorted(AttrList);
Le SET OF se trie, pas le SEQUENCE OF
C'est la distinction la plus souvent confondue, et se tromper de sens est aussi dommageable que de l'ignorer. Un SEQUENCE OF est une collection ordonnée : son ordre fait partie de la valeur et vous ne devez jamais le trier. Un SET OF est non ordonné en tant que valeur, donc le DER impose un ordre canonique pour rendre l'encodage unique. Trier un SEQUENCE OF corrompt des données ; laisser un SET OF non trié produit quelque chose qui n'est pas du DER
- SET OF, à trier :
SignedAttributesetUnsignedAttributesdansSignerInfo, l'ensembledigestAlgorithms, les ensemblescertificatesetcrlsdansSignedData, l'ensemblesignerInfos, l'ensemble de valeurs dans chaqueAttributeindividuel, et l'ensemble d'attributs dans chaqueRelativeDistinguishedNamed'un Name X.501 - SEQUENCE OF, jamais trié : la liste de RDN qui constitue un
Name, le champcertsdeSigningCertificateV2, les listes d'extensions, et les listes de general-name qu'elles contiennent
Un RDN est un SET OF niché dans un SEQUENCE OF un niveau au-dessus, donc un distinguished name contient les deux règles en deux niveaux d'imbrication. Cette disposition explique aussi pourquoi les ensembles à un seul membre sont si indulgents : un ensemble à un élément est trivialement trié, c'est pourquoi les ensembles de valeurs d'attributs construits avec W.SetOf dans BuildSignedAttrs étaient corrects tels quels et n'ont pas eu besoin de changement. Le bug ne vit que là où un ensemble contient vraiment deux membres ou plus, et les RDN multi-valués sont assez rares pour que la plupart des encodeurs de names s'en sortent avec la même erreur pendant des années
Comment empêcher ce bug de revenir ?
Affirmez sur les octets, pas sur les verdicts. Le contrôle durable le moins cher est un auto-audit qui parcourt sa propre sortie : analyser le signedAttrs émis, comparer chaque membre au précédent avec la même comparaison de chaîne d'octets non signée qu'utilise le writer, et faire échouer le build si une paire est dans le désordre. Ça s'exécute en microsecondes, ça n'a besoin d'aucune bibliothèque externe, et contrairement à un aller-retour de vérification, ça teste la propriété que la spécification énonce réellement
// Parcourt les membres d'un SET OF et confirme l'ordre X.690 11.6.
// Members est la liste passée à SetOfSorted, ou le résultat du
// ré-analyse d'un bloc 31 xx / A0 xx émis
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
I: Integer;
begin
for I:= 1 to Length(Members)- 1 do
if CompareDerBytes(Members[I- 1], Members[I])> 0 then
Exit(False);
Result:= True;
end;
// Dans le test de signature, ne vous arrêtez pas à « notre vérificateur dit valide ».
// Épinglez les octets que le signataire a hachés
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');
Deux limites méritent d'être dites clairement. Trier signedAttrs ne rend pas une signature interopérable à lui seul, car tout le reste du conteneur doit aussi tenir, et les raisons pour lesquelles les validateurs refusent des signatures PAdES par ailleurs saines sont assez variées pour remplir leur propre discussion. Et un SET OF trié est une propriété de ce que vous écrivez, pas de ce que vous lisez : PDFium Component doit encore accepter une signature entrante dont les signedAttrs arrivent non triés, car ces fichiers existent dans la nature et la clause 5.4 de la RFC 5652 dit au vérificateur de ré-encoder plutôt que de rejeter. Strict en sortie, tolérant en entrée, comme d'habitude. Le writer DER, le builder CMS et le chemin de signature décrit dans la procédure pas à pas PAdES B-B sont tous livrés en source Pascal lisible avec le composant Delphi PDFium, ce qui est tout l'intérêt de livrer du code source pour une bibliothèque de signature : quand un validateur quelque part en Europe n'est pas d'accord avec votre document, vous pouvez lire les octets exacts que votre code a émis et la clause exacte qu'ils violent, au lieu d'ouvrir un ticket et d'attendre