Un dump hexadécimal d'un signatureAlgorithm RSASSA-PSS qui finit en A0 0F … A1 1C … 02 01 20 02 01 01 a l'air complet : SHA-256, MGF1, un sel de 32 octets et un trailer de 1. Un décodeur strict rejette ce bloc de paramètres carrément, et un indulgent lit la longueur de sel comme 20. Dans les deux cas, une signature dont l'arithmétique RSA est correcte revient invalide. La RFC 4055, §3.1, explique pourquoi : chaque champ de RSASSA-PSS-params porte un tag de contexte explicite, si bien que le sel appartient dans A2, et en DER un trailerField égal à son défaut ne doit pas apparaître du tout
Cela donne à une signature CMS deux moitiés qui peuvent échouer indépendamment. L'une est l'opération cryptographique ; l'autre est l'ASN.1 qui dit au vérificateur comment l'opération a été menée. Ayez la première juste et la seconde fausse, et un vérificateur qui suit la spécification ne peut pas distinguer votre document d'une falsification. La moitié ASN.1 est celle que couvre cet article
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 déduire les paramètres de l'OID d'algorithme. PKCS#1 v1.5 est entièrement épinglé par sha256WithRSAEncryption, si bien que ses paramètres sont un NULL nu. PSS est paramétré par un haché de message, une fonction de génération de masque avec son propre haché, et une longueur de sel, et la RFC 8017, §A.2.3, laisse les trois au signataire. L'AlgorithmIdentifier porte les choix du signataire, et le vérificateur doit les reproduire exactement avant qu'EMSA-PSS-VERIFY puisse tourner
Un bloc que le vérificateur ne peut pas analyser termine la vérification avant toute exponentiation modulaire. Un bloc qu'il analyse différemment est pire. La RFC 4055, §3.1, donne à saltLength un défaut de 20, si bien qu'un décodeur qui ne trouve jamais le champ du sel fait tourner EMSA-PSS-VERIFY avec un sel de 20 octets contre une signature calculée avec 32. Le résultat est rapporté comme une signature mauvaise, sans rien qui pointe les métadonnées. La rigueur du vérificateur décide lequel des deux échecs vous voyez, et aucun des deux messages d'erreur ne mentionne l'AlgorithmIdentifier
Les quatre champs de RSASSA-PSS-params et leurs tags
La RFC 4055, §3.1, définit RSASSA-PSS-params comme une SEQUENCE de quatre champs, chacun avec un tag de contexte explicite et une valeur DEFAULT. En DER, le tagging explicite enveloppe chaque champ dans un TLV de contexte construit — A0 à A3 — avec l'encodage universel propre du champ imbriqué dedans :
| Champ | Tag (octet DER) | DEFAULT | Valeur ETSI TS 119 312 §7 | Encodé ? |
|---|---|---|---|---|
hashAlgorithm | [0] (A0) | sha1 | SHA-256 | Oui |
maskGenAlgorithm | [1] (A1) | mgf1SHA1 | MGF1 avec SHA-256 | Oui |
saltLength | [2] (A2) | 20 | 32 (la longueur du condensé) | Oui |
trailerField | [3] (A3) | trailerFieldBC (1) | 1 | Non, il vaut le défaut |
Les tags sont là parce que chaque champ est optionnel par son défaut. hashAlgorithm et maskGenAlgorithm sont tous deux des SEQUENCE AlgorithmIdentifier ; sans tag, un décodeur face à un bloc de paramètres qui tient une seule SEQUENCE ne pourrait pas dire laquelle des deux il tient. Avec les tags, le numéro de tag nomme le champ peu importe quels voisins l'encodeur a laissés de côté
Choisissez un sel égal à la longueur du condensé et gardez-le là. Certaines API offrent « sel maximum » ou « longueur de sel auto », ce qui produit une valeur qui dépend de la taille de la clé ; c'est légal sous RFC 8017, mais l'AlgorithmIdentifier doit alors énoncer un nombre qui change de clé en clé, et un sel de 32 octets pour SHA-256 est ce que ETSI TS 119 312, §7, demande de toute façon. Les backends de signature intégrés de PDFium Component l'épinglent tous à 32 et passent le même nombre à l'appel de plateforme : cbSalt dans la BCRYPT_PSS_PADDING_INFO remise à NCryptSignHash sous Windows, sLen dans CK_RSA_PKCS_PSS_PARAMS pour un token PKCS#11, EVP_PKEY_CTX_set_rsa_pss_saltlen sous OpenSSL, et le sel à longueur de condensé que kSecKeyAlgorithmRSASignatureDigestPSSSHA256 implique sous macOS
Écrire GetSignatureAlgorithmParams pour votre propre ICmsSigner
Dans PDFium Component, chaque backend de signature implémente l'interface ICmsSigner de FPdfCms.pas, et le bloc de paramètres vient d'une méthode, GetSignatureAlgorithmParams. Le bâtisseur CMS apparie son résultat avec l'OID de GetSignatureAlgorithmOid par TDerWriter.AlgIdWithParams. Vous rencontrez cette méthode quand vous branchez un signataire à vous par ConfigurePadesSignerProvider — un HSM réseau, un service de signature, un pilote de carte à puce que les backends intégrés ne couvrent pas. L'encodage que la plupart des gens écrivent d'abord tague les deux AlgorithmIdentifiers, parce que ceux-ci demandent visiblement un enveloppement, et laisse les deux entiers nus :
// Une première tentative courante : [0] et [1] tagués, [2] et [3] non
Result := W.Sequence(Concat4(
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'),
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.IntegerOf(32), // INTEGER nu là où [2] EXPLICIT est exigé
W.IntegerOf(1))); // trailerField égal au DEFAULT : doit être absent
Parcourez-le comme le fait un décodeur. Il voit A0 et lit le haché, voit A1 et lit la fonction de génération de masque, puis rencontre 02 01 20, un INTEGER universel. RSASSA-PSS-params n'a aucun membre INTEGER sans tag nulle part, si bien qu'un décodeur strict s'arrête. Un indulgent saute l'élément inconnu, ne trouve jamais d'A2, assigne le sel par défaut de 20, puis bute sur un second INTEGER égaré, 02 01 01. Aucun des deux chemins ne finit à 32
La version conforme emploie trois appels à TDerWriter.ContextSpecific depuis FPdfAsn1.pas, un par champ non défaut, chacun avec Constructed à True pour que le résultat soit un enveloppement explicite. Il n'y a pas de ligne pour le champ trailer. C'est le même bloc que les backends Windows CNG, trousseau macOS, PKCS#11 et OpenSSL émettent, écrit ici comme un signataire personnalisé le porterait :
uses
System.SysUtils, FPdfSha256, FPdfAsn1, FPdfCms;
type
TMyHsmSigner = class(TInterfacedObject, ICmsSigner)
private
FUsePss: Boolean;
public
function GetDigestAlgorithmOid: AnsiString;
function GetSignatureAlgorithmOid: AnsiString;
function GetSignatureAlgorithmParams: TBytes;
function GetSignerId: TCmsSignerId;
function SignSignedAttrsDigest(const Digest: TSHA256Digest): TBytes;
end;
// Votre propre classe ; les quatre autres méthodes parlent à votre HSM et ne sont pas montrées
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if not FUsePss then
Exit(nil); // v1.5 et ECDSA : AlgIdWithParams écrit NULL pour nil
W := TDerWriter.Create;
try
// RFC 4055 3.1 : [0] haché, [1] MGF1 avec son haché, [2] sel.
// [3] trailerField = 1 égale son DEFAULT, donc X.690 11.5 l'omet
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;
TDerWriter.AlgId écrit un AlgorithmIdentifier avec paramètres NULL, ce que la RFC 4055, §2.1, dit aux encodeurs de générer pour les identifiants SHA-256 imbriqués. Les octets résultants sont assez courts pour se vérifier à l'œil : un 30 34 extérieur, puis A0 0F autour de l'AlgorithmIdentifier SHA-256 de 15 octets, A1 1C autour de l'AlgorithmIdentifier MGF1 de 28 octets, et A2 03 02 01 20 pour le sel. Le sel que vous écrivez dans GetSignatureAlgorithmParams doit correspondre à celui que votre HSM utilise réellement ; si le service n'expose que « PSS avec SHA-256 » et ne vous dit pas la longueur du sel, renseignez-vous avant de livrer, parce que l'AlgorithmIdentifier est une affirmation à son sujet
Pourquoi trailerField est-il omis plutôt que tagué [3] ?
X.690, §11.5, dit qu'un encodeur DER ne doit pas encoder un composant dont la valeur égale son DEFAULT. trailerField a DEFAULT trailerFieldBC, l'entier 1, et PSS tel que la RFC 8017 le définit n'utilise que ce trailer. Alors la réparation évidente de la première tentative — remplacer W.IntegerOf(1) par W.ContextSpecific(3, W.IntegerOf(1), True) — produit A3 03 02 01 01, qu'un décodeur BER permissif accepte et qu'un décodeur DER strict est en droit de rejeter. La valeur est juste. Sa présence ne l'est pas
La même règle explique pourquoi les trois autres champs apparaissent : SHA-256 n'est pas sha1, MGF1 avec SHA-256 n'est pas mgf1SHA1, et 32 n'est pas 20. Un signataire en SHA-1 avec un sel de 20 octets réduirait tout le bloc à une SEQUENCE vide, 30 00. Cette forme piège quiconque présume que « pas de paramètres » s'écrit toujours 05 00 ; pour id-RSASSA-PSS dans CMS le champ de paramètres est exigé, une SEQUENCE vide veut dire « tous les défauts », et NULL n'est pas une valeur RSASSA-PSS-params valide
BER permet à un encodeur d'inclure un composant à valeur de défaut ; DER l'interdit, parce que le DER existe pour qu'une valeur ait exactement un encodage, et une signature sur une structure à deux encodages légaux se prête à la dispute. Le bloc de paramètres ne peut pas échapper au DER ici. Il se trouve dans le signatureAlgorithm extérieur du SignerInfo, et il est copié dans les attributs signés par l'attribut cmsAlgorithmProtection de la RFC 6211, où chaque octet est couvert par la signature
Vérifier un CMS que vous n'avez pas bâti
Cette copie dans cmsAlgorithmProtection n'attrape pas un bloc mal tagué. L'attribut et le signatureAlgorithm extérieur viennent du même appel, si bien qu'ils correspondent toujours, et une comparaison de la structure contre elle-même passe. Ce qui attrape le problème est un décodeur qui lit le champ des paramètres champ par champ. InspectPadesSignatureAlgorithm de PDFium Component dans FPdfPades.pas fait cela : il n'accepte que A0 à A3, chacun au plus une fois et en ordre croissant, remplit les défauts de la RFC 4055 pour tout ce qui manque, et rapporte les valeurs effectives dans TPadesRsaPssParameters. Un INTEGER nu dans le bloc laisse SignaturePss.Valid à False, et EvaluatePadesSignatureAlgorithm pose alors PolicyStatus à pcsInvalid. Il rejette aussi un haché PSS qui diffère de l'algorithme de condensé du SignerInfo, exactement ce que produirait un repli indulgent vers sha1
Cela compte le plus avec la signature à distance, où un service externe renvoie un CMS fini et CompletePadesRemoteSignature l'incorpore dans le PDF préparé. Vérifiez le blob avant de l'incorporer. Le rejeter à ce moment coûte un appel d'API ; l'apprendre du validateur d'un client coûte un tour de re-signature avec qui fait tourner le service :
uses
System.SysUtils, FPdfPades;
// Contrôle suggéré que vous ajoutez vous-même avant CompletePadesRemoteSignature
function CheckRemotePssCms(const CmsDer: TBytes; out Why: string): Boolean;
const
OID_RSASSA_PSS = '1.2.840.113549.1.1.10';
var
Info: TPadesSignatureAlgorithmInfo;
Values: string;
begin
Result := False;
Why := '';
// nil : laisse l'inspecteur localiser le certificat signataire dans le CMS
if not InspectPadesSignatureAlgorithm(CmsDer, nil, Info) then
begin
Why := 'CMS SignerInfo could not be parsed';
Exit;
end;
if Info.SignatureAlgorithmOid = OID_RSASSA_PSS then
begin
if not Info.SignaturePss.Valid then
begin
Why := 'RSASSA-PSS-params does not decode as RFC 4055 3.1';
Exit;
end;
// Valeurs effectives, défauts inclus : un A2 manquant se montre comme 20 ici
Why := Format('hash %s, MGF hash %s, salt %d', [
string(Info.SignaturePss.HashAlgorithmOid),
string(Info.SignaturePss.MaskGenHashAlgorithmOid),
Info.SignaturePss.SaltLength]);
end;
if Info.PolicyStatus = pcsInvalid then
Exit; // Why garde encore les valeurs décodées pour le log
Values := Why;
Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
if Result then
Why := Values; // le contrôle de protection efface Why en cas de succès
end;
Journalisez la chaîne Why même en cas de succès. Une longueur de sel de 20 à côté d'un haché SHA-256 est l'empreinte d'un bloc de paramètres dont le A2 a disparu, quoi qu'en disent les logs du service. Pour un coup d'œil rapide sans code, openssl asn1parse -inform DER -in signature.p7s imprime l'arbre ; sous l'OID id-RSASSA-PSS vous voulez voir cont [ 2 ] enveloppant un INTEGER de 20 hexadécimal, et aucun cont [ 3 ] du tout. Un INTEGER de premier niveau dans cette SEQUENCE est l'encodage mal tagué du premier bloc de code
Où cela s'inscrit dans le reste de l'histoire PSS
L'encodage des paramètres est l'une des trois façons séparées dont PSS peut mal tourner dans une signature PAdES, et les garder séparées raccourcit le débogage. Sous macOS, une clé particulière ou un système plus ancien peut ne pas offrir PSS, et le backend signe alors avec PKCS#1 v1.5 et déclare sha256WithRSAEncryption pour coller ; cette question de capacité est couverte dans la signature PAdES avec une identité du trousseau macOS. Un token PKCS#11 peut lire CK_RSA_PKCS_PSS_PARAMS avec une largeur d'entier différente de celle écrite par l'appelant, une question d'ABI couverte dans CK_ULONG et le piège de packing PKCS#11. La troisième est celle ci-dessus : la clé est volontaire, le token calcule les bons octets, et le DER qui les décrit ne suit pas la RFC 4055, §3.1
Le SET d'attributs signés a sa propre version de l'angle mort d'auto-cohérence, où un SET haché dans un ordre et émis dans un autre paraît bon jusqu'à ce qu'un vérificateur étranger recalcule le haché ; voyez CMS signedAttrs et le tri DER SET OF. Pour le cycle préparer, signer à distance, compléter dans lequel le contrôle ci-dessus se branche, la signature PAdES à distance avec un HSM couvre le protocole en deux phases et ce que l'étape de complétion refuse
Le composant Delphi PDFium livre ses backends de signature en source, si bien que les corps GetSignatureAlgorithmParams des unités Windows, macOS, PKCS#11 et OpenSSL sont là à lire et à comparer, octet par octet, à la sortie de votre propre signataire