Article technique

Taguer RSASSA-PSS-params selon la RFC 4055 en Delphi

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 :

ChampTag (octet DER)DEFAULTValeur ETSI TS 119 312 §7Encodé ?
hashAlgorithm[0] (A0)sha1SHA-256Oui
maskGenAlgorithm[1] (A1)mgf1SHA1MGF1 avec SHA-256Oui
saltLength[2] (A2)2032 (la longueur du condensé)Oui
trailerField[3] (A3)trailerFieldBC (1)1Non, 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é

Schéma PDFium Component de RSASSA-PSS-params selon la RFC 4055 : hashAlgorithm A0, maskGenAlgorithm A1 et saltLength A2 portent des balises contextuelles explicites avec des valeurs DEFAULT, le profil ETSI émet SHA-256, MGF1 avec SHA-256 et un sel de 32, et trailerField A3 vaut trailerFieldBC donc DER l'omet entièrement
Chaque champ est balisé précisément parce que chaque champ est facultatif par sa valeur par défaut, donc le numéro de balise identifie le champ quels que soient les voisins que l'encodeur laisse 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

Schéma du piège de tagging de RSASSA-PSS-params : après A0 et A1, un bloc mal tagué rencontre un 02 01 20 nu là où appartient la balise explicite A2, un décodeur strict s'arrête et un indulgent se rabat sur le sel par défaut de 20 octets, tandis que l'encodage correct enveloppe le sel de 32 octets dans A2
RSASSA-PSS-params n'a aucun membre INTEGER sans tag, si bien que des octets égarés y sont soit un échec d'analyse, soit un repli silencieux sur la longueur de sel par défaut, et aucun des deux chemins n'atteint le 32 du signataire

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

Schéma PDFium Component de la règle X.690 11.5 dans les paramètres PSS : trailerField égal à son DEFAULT trailerFieldBC doit rester absent parce qu'un enveloppement A3 tagué est l'encodage que le DER strict rejette, tandis que saltLength 32 diffère du défaut 20 et doit être présent comme A2
Le DER existe pour qu'une valeur ait exactement un encodage, et un composant égal à son défaut possède déjà l'encodage le plus court qui soit, à savoir ne pas apparaître du tout

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