PDFium Component version 3.114.20 corrige l'encodage de RSASSA-PSS-params dans les trois moteurs de signature PAdES, Windows CNG, macOS Keychain et PKCS#11. La RFC 4055 §3.1 donne à chaque champ de RSASSA-PSS-params une balise contextuelle explicite, de [0] à [3], et les moteurs émettaient saltLength comme un INTEGER universel nu tout en écrivant un trailerField égal à sa valeur par défaut. Les octets de signature étaient corrects depuis le début. L'AlgorithmIdentifier qui les décrit ne l'était pas, et cela seul suffit à ce qu'un vérificateur rejette la signature
La partie frustrante est l'endroit où le bug se cache. Une signature CMS a deux moitiés, l'opération cryptographique et l'ASN.1 qui dit au vérificateur comment l'opération a été effectuée. Réussissez la première et ratez la seconde, et le résultat est un document qu'aucun outil suivant la spécification ne peut distinguer d'une falsification. Cet article ne porte que sur cette seconde moitié : comment RSASSA-PSS-params doit être balisé, comment trois moteurs se sont trompés de la même façon, et à quoi ressemble le DER corrigé en termes de TDerWriter
Pourquoi un vérificateur rejette-t-il une signature RSASSA-PSS dont les octets sont corrects ?
Parce que RSASSA-PSS est le seul schéma RSA où le vérificateur ne peut pas récupérer les paramètres depuis la signature elle-même. Le remplissage PKCS#1 v1.5 est entièrement déterminé par l'OID sha256WithRSAEncryption, donc ses paramètres sont un NULL nu et il n'y a rien à rater. PSS est paramétré par une fonction de hachage, une fonction de génération de masque avec son propre hachage, et une longueur de sel, et la RFC 8017 §A.2.3 laisse les trois ouvertes. Le signataire les choisit, l'AlgorithmIdentifier les transporte, et le vérificateur doit les reproduire exactement avant même que EMSA-PSS-VERIFY ne puisse commencer
Donc quand PDFium Component signe avec SHA-256, MGF1 sur SHA-256 et un sel de 32 octets, ces trois faits doivent survivre à l'encodage DER ici et au décodage DER dans une autre implémentation. Un bloc de paramètres que le vérificateur ne peut pas analyser met fin à la vérification avant toute exponentiation modulaire. Un bloc qu'il analyse différemment est pire, parce que la RFC 4055 §3.1 donne à saltLength une valeur par défaut de 20. Un décodeur qui saute un champ qu'il ne reconnaît pas retombe sur cette valeur par défaut, exécute EMSA-PSS-VERIFY avec un sel de 20 octets contre une signature calculée avec 32, et rapporte une signature invalide sans le moindre indice que le problème est dans les métadonnées plutôt que dans la clé. Les deux issues sont ce que produisait l'encodage de la 3.114.19, selon la sévérité du vérificateur, et ni l'une ni l'autre ne pointe vers l'AlgorithmIdentifier
Ce que la RFC 4055 §3.1 exige réellement de RSASSA-PSS-params
La RFC 4055 §3.1 définit RSASSA-PSS-params comme une SEQUENCE de quatre champs, chacun portant une balise contextuelle explicite et une valeur DEFAULT :
// RSASSA-PSS-params ::= SEQUENCE {
// hashAlgorithm [0] HashAlgorithm DEFAULT sha1,
// maskGenAlgorithm [1] MaskGenAlgorithm DEFAULT mgf1SHA1,
// saltLength [2] INTEGER DEFAULT 20,
// trailerField [3] TrailerField DEFAULT trailerFieldBC
// }
Le balisage explicite en DER signifie que chaque champ est enveloppé dans un TLV contextuel construit, A0 pour [0], A1 pour [1], A2 pour [2] et A3 pour [3], avec l'encodage universel de la valeur imbriqué dedans. Chaque champ est balisé précisément parce que chaque champ est facultatif par sa valeur par défaut. Sans balises, un décodeur ne pourrait pas dire si une SEQUENCE contenant un seul AlgorithmIdentifier transporte hashAlgorithm ou maskGenAlgorithm, puisque les deux sont des types SEQUENCE ; avec elles, le numéro de balise identifie le champ quels que soient les voisins présents. Les valeurs que PDFium Component émet suivent le profil d'ETSI TS 119 312 §7, SHA-256, MGF1 avec SHA-256 et un sel égal à la longueur du condensat, et elles reflètent exactement ce qu'on dit à chaque appel de signature de la plateforme : un BCRYPT_PSS_PADDING_INFO avec cbSalt à 32 pour NCryptSignHash, un CK_RSA_PKCS_PSS_PARAMS avec sLen à 32 pour le mécanisme PKCS#11, et l'algorithme PSS de signature de condensat SHA-256 dans le framework Security
Comment trois moteurs ont fait la même erreur
L'encodage de la 3.114.19 balisait les deux premiers champs et laissait les deux derniers nus, à l'identique dans TWinCmsSigner, TKeychainCmsSigner et TPkcs11CmsSigner. Cette symétrie n'est pas une coïncidence : les trois implémentent l'interface ICmsSigner de FPdfCms.pas, et leurs corps de GetSignatureAlgorithmParams ont été écrits d'après un seul gabarit. Le gabarit se lisait ainsi :
// Avant la 3.114.20 : [0] et [1] balisés, [2] et [3] non
Result := W.Sequence(Concat4(
W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
W.IntegerOf(32), // INTEGER nu là où un [2] EXPLICIT était exigé
W.IntegerOf(1))); // trailerField, égal au DEFAULT, doit être absent
Un décodeur qui parcourt cette SEQUENCE voit A0, lit l'algorithme de hachage, voit A1, lit la fonction de génération de masque, puis rencontre 02 01 20. C'est un INTEGER universel, et RSASSA-PSS-params n'a aucun membre INTEGER non balisé nulle part. Un décodeur strict s'arrête là. Un décodeur indulgent saute l'élément non reconnu, ne trouve jamais de A2, attribue à saltLength sa valeur par défaut de 20, puis tombe sur un second INTEGER égaré, 02 01 01, et a de nouveau le même problème. Aucun des deux chemins n'arrive à un sel de 32 octets. Un gabarit partagé est efficace quand il est juste et une façon tout aussi efficace de se tromper trois fois quand il ne l'est pas, et c'est pourquoi le correctif a atterri dans les trois unités en un seul commit et pourquoi les trois corps de méthode restent structurellement identiques après coup. Un futur moteur devrait copier le bloc depuis l'un de ceux-ci plutôt que de le redériver, parce que c'est exactement dans la dérivation que l'erreur a été commise
Pourquoi trailerField est-il omis plutôt que balisé en [3] ?
Parce que X.690 §11.5 dit qu'un encodeur DER ne doit pas encoder un composant dont la valeur égale son DEFAULT, et trailerField a pour DEFAULT trailerFieldBC, qui est l'entier 1. La correction évidente de l'ancien code, remplacer le W.IntegerOf(1) nu par W.ContextSpecific(3, W.IntegerOf(1), True), produit un bloc qu'un décodeur BER permissif accepte et qu'un décodeur DER strict est en droit de rejeter. La valeur n'est pas fausse. Sa présence l'est. La même règle explique pourquoi les trois autres champs sont présents : SHA-256 n'est pas le sha1 par défaut, MGF1 avec SHA-256 n'est pas le mgf1SHA1 par défaut, et 32 n'est pas le 20 par défaut. Si le moteur avait signé avec SHA-1 et un sel de 20 octets, la RFC 4055 §3.1 réduirait les paramètres à une SEQUENCE vide, 30 00, et c'est cette SEQUENCE vide plutôt que NULL qu'un vérificateur attend. PDFium Component n'émet jamais cette forme parce qu'il ne signe jamais avec ces valeurs, mais c'est le cas qui attrape quiconque suppose que « pas de paramètres » s'écrit toujours 05 00
C'est la distinction entre DER et BER qui compte pour les signatures en particulier. BER laisse un encodeur inclure un composant à valeur par défaut ; DER l'interdit, parce que DER existe pour qu'une valeur ait exactement un encodage, et une signature sur une structure à deux encodages légaux est une signature sur laquelle on peut ergoter. Tout ce qui se trouve dans les signedAttrs d'un CMS est du DER pour cette raison, et le bloc de paramètres voyage à l'intérieur des signedAttrs via l'attribut cmsAlgorithmProtection en plus de l'emplacement extérieur signatureAlgorithm, donc il ne bénéficie d'aucune exemption
L'encodage corrigé avec TDerWriter
PDFium Component construit désormais les paramètres avec trois appels à TDerWriter.ContextSpecific de FPdfAsn1.pas, un par champ non par défaut, chacun avec l'argument Constructed à True pour produire le conteneur de balise explicite, et aucune ligne du tout pour le champ trailer. Voici le corps de TWinCmsSigner.GetSignatureAlgorithmParams avec les OID écrits en clair ; les unités Keychain et PKCS#11 écrivent les mêmes valeurs sous les noms OID_SHA256, OID_MGF1 et OID_RSASSA_PSS :
function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if FPaddingScheme = psRsaPss then
begin
W := TDerWriter.Create;
try
// La RFC 4055 3.1 balise les quatre champs. saltLength est [2] ; un
// INTEGER nu ici est lu comme le début d'un autre champ. trailerField
// est [3] avec DEFAULT 1, et X.690 11.5 interdit d'encoder une valeur
// égale à la valeur par défaut, donc il est entièrement omis
Result := W.Sequence(Concat3(
W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID('1.2.840.113549.1.1.8'), // id-mgf1
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.ContextSpecific(2, W.IntegerOf(32), True)));
finally
W.Free;
end;
end
else
Result := nil; // PKCS#1 v1.5 et ECDSA : AlgIdWithParams écrit NULL
end;
Deux détails de la machinerie environnante comptent. TDerWriter.AlgId produit un AlgorithmIdentifier avec des paramètres NULL, ce que la RFC 4055 §2.1 demande aux encodeurs de générer pour le hashAlgorithm imbriqué et pour le hachage interne de MGF1. Et le constructeur CMS dans FPdfCms.pas associe l'OID de signature à ces octets via TDerWriter.AlgIdWithParams, qui substitue NULL quand les paramètres sont nil ; c'est pourquoi psRsaPkcs1v15 et psEcdsa renvoient simplement nil et n'ont jamais été affectés, et pourquoi 1.2.840.113549.1.1.10, id-RSASSA-PSS, est le seul OID de signature des trois à porter un vrai bloc de paramètres. Les octets résultants pour le profil SHA-256 sont fixes et assez courts pour être vérifiés à l'œil : une SEQUENCE extérieure 30 34 contenant A0 0F autour de l'AlgorithmIdentifier SHA-256 de 15 octets, A1 1C autour de l'AlgorithmIdentifier MGF1 de 28 octets dont les propres paramètres sont ce même AlgorithmIdentifier SHA-256, et A2 03 02 01 20 pour le sel. Si un vidage de votre signatureAlgorithm montre 02 01 20 au niveau supérieur de la SEQUENCE de paramètres plutôt qu'à l'intérieur d'un A2, vous regardez l'encodage de la 3.114.19
Pourquoi la suite de tests n'a-t-elle pas attrapé un AlgorithmIdentifier malformé ?
Parce que les tests PAdES pilotent le constructeur CMS via un signataire factice qui rapporte sha256WithRSAEncryption et renvoie nil depuis GetSignatureAlgorithmParams, donc le bloc de paramètres PSS n'a jamais été construit dans un test. C'est une conception raisonnable pour des tests qui doivent tourner sans magasin de certificats, sans Keychain ni jeton, et elle a un angle mort à la forme précise : tout ce que seul un vrai moteur produit n'est exercé que par un vrai moteur. La seconde couche est plus intéressante. PDFium Component place aussi l'AlgorithmIdentifier de signature, paramètres compris, dans l'attribut signé cmsAlgorithmProtection de la RFC 6211, et un vérificateur compare cette copie à l'emplacement extérieur signatureAlgorithm. Les deux copies venaient du même appel, donc elles correspondaient parfaitement et toutes les vérifications de cohérence interne passaient. L'encodage était auto-cohérent et faux, la catégorie de bug qu'aucune comparaison d'une structure avec elle-même ne peut révéler, et la même leçon avec une autre structure est racontée dans CMS signedAttrs et le tri DER SET OF, où un SET haché dans un ordre et émis dans un autre semblait correct jusqu'à ce qu'un vérificateur étranger recalcule le hachage
Ce qui attrape cette classe de bug, c'est un décodeur qui n'a pas été écrit par l'auteur de l'encodeur, exécuté contre la sortie réelle du moteur réel. Le chemin de vérification Windows de PDFium Component passe par CryptoAPI plutôt que par le lecteur de la bibliothèque, et c'est une signature PSS rejetée à cet endroit qui a ramené aux paramètres. Tout ASN.1 qu'une implémentation émet pour que d'autres implémentations le lisent mérite au moins un aller-retour par un décodeur qu'elle ne contrôle pas, et plus la structure a de valeurs par défaut et de balises, plus cet aller-retour vaut la peine
Où cela s'inscrit dans le reste de l'histoire PSS
Ce correctif est indépendant des deux autres endroits où PSS peut mal tourner dans une signature PAdES, et les garder séparés raccourcit le débogage. Le moteur macOS peut constater qu'une clé donnée ou un système plus ancien refuse PSS et rétrograder vers PKCS#1 v1.5, et l'AlgorithmIdentifier doit suivre la rétrogradation ; c'est une question de capacités, traitée dans signer en PAdES avec une identité du Keychain macOS. Le moteur PKCS#11 peut remettre au jeton un CK_RSA_PKCS_PSS_PARAMS dont la disposition est lue différemment par le jeton à cause d'un décalage de largeur d'entier ; c'est une question d'ABI, traitée dans CK_ULONG et le piège du packing PKCS#11. Cet article porte sur la troisième défaillance, celle où la clé était consentante, où le jeton a calculé les bons octets, et où le DER décrivant le résultat n'était pas conforme à la RFC 4055 §3.1
Un signataire qui déclare PSS assume une obligation que le signataire v1.5 n'a jamais eue : décrire ses propres paramètres sous une forme qu'une autre implémentation décode vers les trois mêmes valeurs. La RFC 4055 §3.1 fixe les balises, X.690 §11.5 fixe quels champs peuvent apparaître, et ETSI TS 119 312 §7 fixe les valeurs qui valent d'être choisies. Les trois moteurs du composant PDFium pour Delphi sont livrés en source, donc le corps de GetSignatureAlgorithmParams ci-dessus est celui que vous pouvez lire, vider et comparer à votre propre vérificateur plutôt que de le prendre sur parole