Article technique

Vérification de signature ECDSA PDF en Delphi : DER vers P1363

Une signatureValue ECDSA à l'intérieur d'un conteneur CMS est une SEQUENCE { INTEGER r, INTEGER s } DER. La fonction Windows CNG BCryptVerifySignature n'accepte ni l'une ni l'autre : elle veut du IEEE P1363 à largeur fixe, r || s, sans balises ni longueurs. HotPDF, le composant VCL natif pour Delphi et C++Builder, convertit entre les deux selon des règles DER strictes avant d'importer une clé

L'échec que cela évite est spécifique et démoralisant. Acrobat ouvre le document et affiche une coche verte. Votre propre vérificateur, parcourant les mêmes octets, renvoie invalide, ou CNG répond STATUS_INVALID_SIGNATURE sans plus d'explication. Rien ne cloche dans la signature. Ce qui cloche, c'est qu'environ soixante-dix octets d'ASN.1 ont été transmis à une API qui attendait soixante-quatre octets d'entiers bruts, et le décalage est invisible tant qu'on ne sait pas qu'il faut le chercher

Pourquoi BCryptVerifySignature rejette-t-il une signature ECDSA valide ?

Parce que les deux côtés de l'appel parlent des encodages de signature différents, et aucun des deux ne l'annonce. ISO 32000-1 §12.8 dit qu'un dictionnaire de signature porte un blob CMS dans /Contents ; la RFC 5652 §5.3 dit que la signatureValue de chaque SignerInfo est une OCTET STRING dont le contenu est défini par l'algorithme de signature. Pour ECDSA, ce contenu est la structure DER SEC 1 : une SEQUENCE contenant deux INTEGER. Elle est de longueur variable par conception, car r et s sont des entiers et le DER retire les octets zéro de tête des entiers

IEEE P1363 adopte le point de vue opposé. Il définit la signature comme la concaténation des deux coordonnées, chacune complétée à gauche par des zéros jusqu'à exactement la largeur en octets du champ de la courbe. Une signature P-256 fait toujours 64 octets. Un encodage DER de la même signature fait normalement 70 ou 71 octets et peut aller d'environ 8 à 72. Passer la forme DER à BCryptVerifySignature condamne l'appel dès la vérification de longueur, ce qui explique pourquoi HotPDF normalise avant de vérifier plutôt qu'après

uses
  HPDFECDSA;

// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
  out ARaw: TBytes): Boolean;
begin
  Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;

Les règles DER qu'un analyseur de signature ne doit pas assouplir

Chaque rejet listé ici est un rejet que HotPDF effectue délibérément, et chacun ferme une voie qu'un analyseur trop tolérant laisserait ouverte. La tentation, en écrivant un convertisseur, est de trouver les deux nœuds INTEGER, d'en copier le contenu, et de passer à la suite. Cela fonctionne sur une entrée bien formée et accepte silencieusement toute une famille de ré-encodages malléables sur une entrée hostile. Ainsi HPDFECDSANormalizeSignature rejette un entier négatif, c'est-à-dire tout r ou s dont le premier octet de contenu a le bit de poids fort à un, car un scalaire ECDSA valide est positif. Il rejette une valeur entièrement nulle, puisque r = 0 ou s = 0 n'est jamais une signature légitime. Il rejette un octet zéro de tête redondant : X.690 §8.3 en autorise exactement un, et seulement lorsque l'octet suivant se lirait autrement comme négatif, si bien qu'un 00 suivi d'un octet inférieur à 0x80 est un ré-encodage, pas une signature. Il rejette un en-tête de longueur non minimal, car X.690 §10.1 exige la forme définie encodée avec le moins d'octets possible, et une longueur en forme longue qui aurait pu être en forme courte est une chaîne d'octets différente portant le même sens. Il rejette un entier plus large que la taille de coordonnée de la courbe, puisqu'une telle valeur ne peut pas être un élément du corps. Et il rejette tout nœud traînant après s, ainsi qu'une SEQUENCE externe dont la longueur totale ne correspond pas à la longueur de l'ensemble du blob

Ces deux derniers points comptent plus qu'ils n'en ont l'air. Des octets traînants après la SEQUENCE sont l'astuce classique de malléabilité de signature : ajoutez du bruit, et un vérificateur laxiste continuera de dire valide alors que la chaîne d'octets qu'il a validée n'est pas celle qui a été signée. Le même instinct guide le durcissement de longueur ASN.1 décrit dans la note sur l'analyse PKCS#12, et c'est le même instinct ici. Dans un chemin de vérification, une structure acceptée qui n'a jamais été émise par un signataire conforme est un défaut, pas une courtoisie

La largeur de coordonnée appartient à la courbe, pas à la signature

HotPDF dérive la largeur de sortie de l'OID de la courbe nommée, jamais de la longueur du DER qu'il vient d'analyser. C'est la seconde moitié de la conversion, et celle qu'il est facile de mal faire subtilement. La RFC 5480 §2.1.1 identifie la courbe dans les paramètres SubjectPublicKeyInfo du certificat, et HPDFECDSACurveFromOID associe les trois OID que HotPDF prend en charge : 1.2.840.10045.3.1.7 pour P-256, 1.3.132.0.34 pour P-384, et 1.3.132.0.35 pour P-521. HPDFECDSACoordinateSize retourne alors 32, 48 ou 66 octets, et le tampon P1363 fait le double : 64, 96 ou 132. Chaque entier décodé est aligné à droite dans sa moitié, si bien qu'un r court est complété à gauche par des zéros plutôt que décalé. P-521 est celle qui piège les gens, car 521 bits font 65,125 octets et arrondissent à 66, donnant une signature de 132 octets qu'aucune intuition en puissance de deux n'aurait prédite. La clé publique voyage à côté sous forme de point EC non compressé selon la RFC 5480 §2.2, c'est-à-dire 0x04 suivi de X et Y, si bien que HotPDF vérifie qu'elle fait exactement 1 + 2 * CoordinateSize octets et commence par 0x04 avant de toucher à CNG

var
  Digest, SigDER, PublicPoint: TBytes;
  Curve: THPDFECDSACurve;
  Res: THPDFECDSAVerifyResult;
begin
  // secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
  Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');

  // PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
  Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);

  case Res of
    evrValid:
      Memo1.Lines.Add('signature verifies');
    evrInvalid:
      Memo1.Lines.Add('signature does not match the digest');
    evrMalformed:
      Memo1.Lines.Add('DER encoding or public point rejected');
    evrUnsupported:
      Memo1.Lines.Add('curve or algorithm not supported here');
    evrProviderUnavailable:
      Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
    evrProviderError:
      Memo1.Lines.Add('CNG returned an unexpected status');
  end;
end;

Notez le dernier paramètre. HPDFECDSAVerifyDigest accepte aussi eseP1363 pour les appelants qui disposent déjà d'une signature à largeur fixe, provenant d'un jeton matériel ou d'un service de signature distant qui retourne r || s brut. Ce chemin impose lui aussi la vérification de longueur et le contrôle de non-nullité sur les deux moitiés, si bien qu'un tampon de la bonne taille entièrement rempli de zéros est refusé plutôt que transmis tel quel au fournisseur

Pourquoi le nom d'algorithme ECDSA générique échoue-t-il sur les anciennes versions de Windows ?

Parce que le nom générique est plus récent que la base de déploiement que vous ciblez. CNG expose un identifiant d'algorithme ECDSA qui déduit la courbe de la clé importée, et c'est la façon la plus propre d'écrire ce code, mais BCryptOpenAlgorithmProvider n'est garanti de le résoudre que sur des versions de Windows plus récentes. Sur une machine plus ancienne, l'appel d'ouverture échoue, le handle de fournisseur reste nul, et chaque vérification ECDSA de votre application rapporte non pris en charge sur une signature parfaitement valide. HotPDF évite ce piège en ouvrant plutôt les identifiants spécifiques à chaque courbe. Il résout ECDSA_P256, ECDSA_P384 et ECDSA_P521 une seule fois, met en cache un handle de fournisseur par courbe, et les ferme à la finalisation de l'unité. Chaque vérification n'effectue alors que le travail peu coûteux : importer une clé publique temporaire depuis un ECCPUBLICBLOB, appeler BCryptVerifySignature, détruire la clé. Pas de LoadLibrary répété, pas de GetProcAddress répété, pas d'ouverture puis de fermeture de fournisseur à chaque signature. La vérification par lots de quelques centaines de documents ressent la différence, tout comme un processus de service qui, autrement, épuiserait les handles de fournisseur sous charge

Les codes de résultat restent honnêtes quant à la distinction. evrProviderUnavailable signifie que la machine n'a pas pu fournir de fournisseur à HotPDF ; evrInvalid signifie que CNG a répondu STATUS_INVALID_SIGNATURE. Confondre ces deux cas en un seul échec, c'est ainsi qu'un problème de déploiement se retrouve signalé à tort comme un document falsifié. La même séparation entre échec d'environnement et échec cryptographique traverse la gestion CNG et CAPI côté signature, couverte dans l'article sur la signature via le magasin de certificats et l'ordre des octets

Quel certificat a signé ceci ? SignerIdentifier recouvre deux choses différentes

La RFC 5652 §5.3 fait de SignerIdentifier un CHOICE, et un vérificateur qui ne gère qu'une seule branche vérifiera silencieusement contre la mauvaise clé. La première branche est issuerAndSerialNumber, une SEQUENCE contenant le Name de l'émetteur en DER brut et l'INTEGER de numéro de série, et la faire correspondre est une comparaison d'octets contre chaque certificat de l'ensemble certificates du CMS. La seconde branche est [0] subjectKeyIdentifier, une OCTET STRING implicitement étiquetée, et la faire correspondre exige de creuser dans le certificat plutôt que de comparer ses champs d'en-tête

Ce creusement comporte une couche qui surprend. L'identifiant de clé vit dans une extension X.509v3, si bien que HotPDF parcourt le champ d'extensions [3] du tbsCertificate, trouve l'extension dont l'OID est 2.5.29.14, saute le BOOLEAN critique optionnel, et prend l'OCTET STRING extnValue. Cette chaîne d'octets n'est pas l'identifiant. Selon la RFC 5280 §4.2.1.2, son contenu est lui-même du DER, et le type KeyIdentifier est une autre OCTET STRING, il faut donc analyser une seconde fois pour atteindre les octets réels. S'arrêter une couche trop tôt revient à comparer une enveloppe de 22 octets à un identifiant de 20 octets, aucun certificat ne correspond jamais, et le vérificateur se rabat sur l'heuristique suivante que vous avez écrite, ce qui est le vrai danger. Prendre le premier certificat de l'ensemble est un raccourci tentant, et il est erroné dès que le CMS porte une chaîne, ce qui est le cas la plupart du temps, car rien n'oblige la feuille à venir en premier. HotPDF n'accepte un certificat non apparié que lorsque le conteneur n'en contient qu'un seul ; en présence de plusieurs certificats, une correspondance exacte de SignerIdentifier est obligatoire. Vérifier un condensé contre la clé publique d'une AC intermédiaire ne produit pas une erreur explicite, cela produit un invalide confiant sur un document par ailleurs correct

var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('signed.pdf') > 0 then
      for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
        if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
          Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
            [String(Info.FieldName), String(Info.PublicKeyAlgorithm),
             String(Info.CurveName), String(Info.HashAlgorithm),
             String(Info.SignerName)]))
        else
          Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
  finally
    Pdf.Free;
  end;
end;

THPDFSignatureInfo.CurveName rapporte P-256, P-384, ou P-521, si bien qu'un journal d'audit enregistre quelle courbe a réellement été utilisée plutôt que le simple mot ECDSA. La mécanique documentaire autour de cet appel, en particulier la façon dont les segments /ByteRange sont hachés et pourquoi le condensé doit être calculé sur le fichier plutôt que sur l'arbre d'objets analysé, fait l'objet de l'article compagnon sur la vérification des signatures numériques PDF

Ce que cela ne vous donne pas

Un résultat vert de HPDFECDSAVerifyDigest ne répond qu'à une seule question : ces octets ont été signés par la clé privée correspondant à cette clé publique. Cela ne dit rien sur le fait que cette clé appartienne à quelqu'un en qui vous devriez avoir confiance. La construction de la chaîne jusqu'à une ancre de confiance, la révocation via CRL ou OCSP, et les vérifications de politique sont un travail séparé, et tout produit qui rapporte une signature valide sans cela rapporte moins que ce que l'utilisateur suppose. Les dates de validité de certificat sont d'ailleurs exposées séparément dans THPDFSignatureInfo précisément pour cette raison : une signature peut se vérifier cryptographiquement alors que le certificat qui l'a produite a expiré depuis deux ans. La prise en charge des courbes est également délibérément restreinte. Trois courbes premières du NIST sont gérées, et une signature sur toute autre courbe retourne non pris en charge plutôt qu'une supposition. Le chemin CNG est réservé à Windows, ce qui est le bon compromis pour un composant VCL mais mérite d'être signalé avant de bâtir un service multiplateforme autour. Et la rigueur n'est pas configurable : il n'existe pas de mode tolérant qui accepterait une longueur DER non minimale parce qu'un signataire ancien en aurait émis une. Si vous rencontrez un tel fichier en production, la réponse honnête est de le consigner et d'aller voir le producteur, pas d'élargir l'analyseur jusqu'à ce que le fichier passe

Le chemin de vérification ECDSA décrit ici fait partie du composant HotPDF standard pour Delphi et C++Builder, aux côtés des chemins RSA PKCS#1 v1.5 et RSA-PSS et de l'enregistrement complet d'informations de signature ; la page produit propose la référence complète des signatures numériques