Article technique

Peut-on signer un PDF en SHA-3 depuis Delphi ?

Vous passez une routine de signature Delphi au SHA3-512 et OpenSSL rejette le résultat : le CMS déclare SHA3-512, mais son attribut messageDigest contient 32 octets là où cet algorithme en produit 64. Oui, du code Delphi peut signer en SHA-3 : SignPadesForDigest dans PDFium Component accepte SHA3-256, SHA3-384 ou SHA3-512 et écrit chaque champ du CMS dépendant du condensat pour ce seul choix, comme l'ISO/TS 32001 s'y attend

Ce que l'ISO/TS 32001 ajoute à une signature PDF 2.0

L'ISO/TS 32001 est une courte extension de l'ISO 32000-2. Elle élargit l'ensemble des fonctions de hachage qu'une signature PDF peut déclarer. La liste de base siège au tableau 260 de l'ISO 32000-2 §12.8.3.1, et pour les sous-filtres à base de CMS elle s'arrête à SHA-256, SHA-384, SHA-512 et RIPEMD-160, avec SHA-1 déprécié. La spécification technique ajoute la famille SHA-3 issue de FIPS 202. SHA-3 est une construction éponge qui ne partage aucune structure interne avec SHA-2, et c'est là tout l'intérêt pour une politique de signature qui veut une seconde famille de hachage sur l'étagère au cas où SHA-2 faiblirait un jour. Dans le CMS, chaque variante est un identifiant d'objet sous l'arc NIST. Les identifiants d'algorithme de signature combinés siègent juste à côté :

TCmsDigestAlgorithmOID de condensatSortieRSA PKCS#1 v1.5ECDSA
cdaSha2562.16.840.1.101.3.4.2.132 octets1.2.840.113549.1.1.111.2.840.10045.4.3.2
cdaSha3_2562.16.840.1.101.3.4.2.832 octets2.16.840.1.101.3.4.3.142.16.840.1.101.3.4.3.10
cdaSha3_3842.16.840.1.101.3.4.2.948 octets2.16.840.1.101.3.4.3.152.16.840.1.101.3.4.3.11
cdaSha3_5122.16.840.1.101.3.4.2.1064 octets2.16.840.1.101.3.4.3.162.16.840.1.101.3.4.3.12

Dans PDFium Component, tout le choix tient en une énumération, TCmsDigestAlgorithm de l'unité FPdfCms. Laissez la clé choisir le membre. Une courbe NIST attend un condensat de sa propre largeur, si bien que P-256 fait paire avec SHA3-256, P-384 avec SHA3-384 et P-521 avec SHA3-512, et un validateur qui applique la politique de courbes et de condensats de l'ISO/TS 32002 marque toute autre paire invalide. RSA n'a pas une telle règle. L'argument de force est de toute façon court : une clé RSA-2048 délivre à peu près 112 bits de sécurité et RSA-3072 environ 128, si bien que SHA3-256 dépasse déjà la clé, tandis que SHA3-512 sur une clé RSA-2048 ajoute 32 octets au CMS et rien à la sécurité. Nous recommandons SHA3-256 pour RSA, sauf si une politique écrite nomme une variante plus grande

Ajouts SHA-3 de l'ISO/TS 32001 pour les signatures PDF depuis Delphi : les clés EC font paire avec le condensat de même largeur et RSA obtient SHA3-256 par défaut dans PDFium Component
La famille SHA-3 rejoint le tableau 260 comme seconde famille de hachage, les courbes EC font paire avec la largeur correspondante, et SHA3-256 dépasse déjà une clé RSA-2048

Cinq champs du CMS qui changent avec le condensat

Échanger la fonction de hachage est la plus petite partie du travail. Une signature CAdES détachée nomme son condensat en cinq endroits, et un vérificateur strict les recoupe. Si l'un d'eux dit encore SHA-256, la signature échoue même si l'arithmétique RSA ou ECDSA est correcte :

  1. digestAlgorithm dans le SignerInfo, reflété dans l'ensemble digestAlgorithms du SignedData, porte l'OID de condensat, par exemple 2.16.840.1.101.3.4.2.9 pour SHA3-384
  2. L'attribut signé messageDigest (RFC 5652) contient le haché des deux plages /ByteRange, si bien que son OCTET STRING fait 32, 48 ou 64 octets
  3. signatureAlgorithm nomme le schéma combiné du tableau ci-dessus. RSASSA-PSS est l'exception : son OID reste id-RSASSA-PSS (1.2.840.113549.1.1.10) et le condensat passe dans les paramètres
  4. Les paramètres RSASSA-PSS répètent le condensat deux fois, comme hashAlgorithm et comme hachage MGF1, et posent saltLength à la longueur du condensat. Omettez-les et un vérificateur suppose SHA-1, MGF1 avec SHA-1 et un sel de 20 octets, ce qui est un autre algorithme ; les règles d'étiquetage des RSASSA-PSS-params de la RFC 4055 comptent ici aussi
  5. L'attribut signé CMSAlgorithmProtection (RFC 6211) copie les identifiants du condensat et de l'algorithme de signature dans les attributs signés, où personne ne peut les échanger à l'insu de personne. Il doit correspondre aux champs 1 et 3 octet pour octet

Un champ ne change pas du tout. Le ESSCertIDv2 à l'intérieur de l'attribut signing-certificate-v2 (RFC 5035) hache le certificat du signataire avec son propre hashAlgorithm, qui vaut SHA-256 par défaut et est indépendant du condensat du SignedData, si bien que PDFium Component le garde à SHA-256 dans l'encodage par défaut. L'erreur qui apparaît le plus dans du code CMS écrit à la main est la longueur du messageDigest. Du code écrit pour SHA-256 tend à contenir le haché dans un enregistrement fixe de 32 octets et à copier cet enregistrement dans l'attribut. Poussez un résultat SHA3-512 à travers et l'attribut reçoit les 32 premiers octets pendant que digestAlgorithm dit .10 : le rejet OpenSSL de l'ouverture. La même erreur vit aussi un étage plus bas, où le EVP_PKEY_sign d'OpenSSL prend la longueur du condensat comme tbslen et la vérifie contre le condensat posé sur le contexte, si bien qu'un signataire qui passe toujours 32 reçoit une erreur de longueur qui ne mentionne jamais SHA-3. PDFium Component calcule le condensat du document depuis l'algorithme que le signataire résolu déclare et dérive les attributs, les paramètres PSS et l'attribut de protection d'algorithme de cette même déclaration, si bien qu'aucun champ ne peut dévier des autres

Les cinq champs du CMS qui changent avec un condensat SHA-3 dans une signature PAdES depuis Delphi, recoupés par des vérificateurs stricts quand PDFium Component les écrit
digestAlgorithm, messageDigest, signatureAlgorithm, les paramètres PSS et CMSAlgorithmProtection doivent nommer un seul condensat, tandis que ESSCertIDv2 garde son propre SHA-256

Signer un PDF local avec SignPadesForDigest

Sur le composant TPdf les points d'entrée SHA-3 sont SignPadesForDigest(const FileName: string; const Options: TPadesSignOptions; DigestAlgorithm: TCmsDigestAlgorithm): Boolean et son jumeau de flux SignPadesToStreamForDigest. Tous deux prennent le même enregistrement TPadesSignOptions que SignPades, et cdaSha256 produit la même signature que la surcharge existante, si bien qu'un seul chemin de code sert les deux familles. Sous Windows, une empreinte SHA-1 sélectionne un certificat dans le magasin MY de l'utilisateur courant. Le backend OpenSSL sur les autres plateformes signe avec la clé et le certificat donnés à ConfigureSslSigningCredentials et refuse une empreinte. Vérifiez aussi la version du document. L'ISO/TS 32001 étend PDF 2.0, et la signature entre comme une mise à jour incrémentale qui garde l'en-tête d'origine, si bien qu'une partie s'appuyant sur les règles PDF 2.0 a besoin d'un fichier 2.0 avant que vous ne signiez

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfPades
  {$IFDEF MSWINDOWS}, FPdfCryptoWin{$ELSE}, FPdfCryptoSsl{$ENDIF};

function Sha3SigningAvailable: Boolean;
begin
{$IFDEF MSWINDOWS}
  Result := WinSha3DigestAvailable;   // fournisseurs SHA-3 CNG, Windows 11 24H2+
{$ELSE}
  Result := SslSha3SigningAvailable;  // False tant que les identifiants ne sont pas configurés
{$ENDIF}
end;

procedure SignWithSha3(const SourceFile, SignedFile, Thumbprint: string;
  const KeyPassword: AnsiString; const Log: TProc<string>);
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Signed: TMemoryStream;
begin
{$IFNDEF MSWINDOWS}
  // OpenSSL signe avec du matériel explicitement configuré ; pas de chaîne dans ce croquis
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('/etc/signing/signer-key.pem'),
    TFile.ReadAllBytes('/etc/signing/signer.der'), nil, KeyPassword);
{$ENDIF}
  if not Sha3SigningAvailable then
    raise Exception.Create('No SHA-3 capable signing backend on this machine');

  Opts := TPadesSignOptions.Default;          // B-B, RSASSA-PSS
{$IFDEF MSWINDOWS}
  Opts.CertificateThumbprint := Thumbprint;   // certificat dans le MY de l'utilisateur courant
{$ENDIF}
  Opts.Reason := 'Approved for release';

  Pdf := TPdf.Create(nil);
  Signed := TMemoryStream.Create;
  try
    Pdf.FileName := SourceFile;
    Pdf.Active := True;
    if Pdf.PdfVersion <> pv20 then
      Log(SourceFile + ' is not a PDF 2.0 file');
    try
      if Pdf.SignPadesToStreamForDigest(Signed, Opts, cdaSha3_256) then
        Signed.SaveToFile(SignedFile);
    except
      on E: EPadesCryptoUnavailable do
        Log('SHA-3 signing unavailable: ' + E.Message);
    end;
  finally
    Signed.Free;
    Pdf.Free;
  end;
end;

Quand le backend ne peut pas produire du SHA-3, l'appel lève EPadesCryptoUnavailable au lieu de retomber sur SHA-256. C'est le comportement qu'on veut. Quelqu'un a demandé SHA3-256 pour une raison, d'ordinaire un document de politique de la partie s'appuyant sur la signature, et une signature SHA-256 produite à sa place passerait tous vos contrôles locaux pour être refusée chez eux, des semaines plus tard, quand plus personne dans votre équipe ne se souvient de quelle machine a signé. Attrapez l'exception là où vous pouvez encore dire à l'opérateur quelle machine manque de l'algorithme

Là où le SHA-3 est disponible : CNG de Windows et OpenSSL

La disponibilité appartient à la machine. Sous Windows le backend par défaut est CNG, et CNG livre des fournisseurs d'algorithmes SHA-3 à partir de Windows 11 24H2 ; sur les builds antérieurs, BCryptOpenAlgorithmProvider pour "SHA3-256" échoue avec STATUS_NOT_FOUND. WinSha3DigestAvailable ouvre une fois les fournisseurs SHA3-256, SHA3-384 et SHA3-512 et met la réponse en cache, puisqu'elle ne peut pas changer pendant que tourne le processus. Partout ailleurs, le backend OpenSSL signe, et ce qu'il peut faire dépend du libcrypto qu'il trouve :

  • OpenSSL 3.x exporte toujours EVP_sha3_256, EVP_sha3_384 et EVP_sha3_512, si bien que la signature SHA-3 marche sur toute plateforme où le backend se charge
  • Le chargeur accepte aussi une bibliothèque de série 1.1 (libcrypto-1_1.dll, libcrypto.so.1.1). Le SHA-3 est arrivé en OpenSSL 1.1.1 et est absent de 1.1.0, si bien que sur d'anciennes images Linux, SslSha3SigningAvailable décide
  • SslSha3SigningAvailable exige aussi des identifiants de signature configurés. Demandé avant ConfigureSslSigningCredentials, il répond False même quand SslLibraryVersion rapporte une très bonne bibliothèque 3.x. Cela ressemble à un backend manquant. Ce n'est qu'un problème d'ordre
  • Un exécutable 32 bits ne peut pas charger libcrypto-3-x64.dll. Quand ce backend a été mis en place, un build Win32 pointé vers la DLL x64 rapportait SslAvailable = False tandis que SslMissingSymbols ne renvoyait rien d'autre que le chemin de la bibliothèque, un symptôme qui se lit comme une liaison de symboles cassée ; la cause était la largeur de bits, et une DLL appariée à l'exécutable a réglé le problème

Un parc Windows qui compte encore des machines antérieures à 24H2 peut garder CNG là où il a le SHA-3 et router le reste à travers OpenSSL avec un fournisseur de signataires. IPadesSignerProvider a deux méthodes. ResolveSslSignerForDigest dans FPdfCryptoSsl renvoie un signataire dont le condensat déclaré, l'algorithme de signature, la longueur du condensat et le sel PSS suivent tous l'algorithme demandé, et avec un fournisseur configuré, ResolvePadesSignerForDigest vérifie que le signataire renvoyé déclare le condensat demandé par l'appel, en levant EPadesCrypto sinon. Un fournisseur bâti pour SHA3-256 ne peut pas estampiller une requête SHA3-384. Le fournisseur est toutefois à l'échelle du processus, si bien qu'un serveur qui signe concurremment devrait le configurer une fois au démarrage plutôt qu'à chaque appel comme le fait ce croquis ; ConfigureSslLibraryPath pointe le chargeur vers libcrypto quand la DLL n'est pas sur le chemin de recherche

Flux de disponibilité de la signature SHA-3 en Delphi : fournisseurs CNG Windows à partir de 24H2, gardes de version OpenSSL et de largeur de bits, et chemin d'échec EPadesCryptoUnavailable dans PDFium Component
La disponibilité appartient à la machine, donc le contrôle se place au démarrage : CNG là où il y a du SHA-3, OpenSSL ailleurs, et aucune retombée silencieuse sur SHA-256
uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfCms, FPdfCrypto, FPdfCryptoWin, FPdfCryptoSsl, FPdfPades;

type
  TOpenSslSha3Provider = class(TInterfacedObject, IPadesSignerProvider)
  private
    FDigest: TCmsDigestAlgorithm;
  public
    constructor Create(ADigest: TCmsDigestAlgorithm);
    function BackendName: string;
    function Resolve(const Options: TPadesSignerOptions): TPadesResolvedSigner;
  end;

constructor TOpenSslSha3Provider.Create(ADigest: TCmsDigestAlgorithm);
begin
  inherited Create;
  FDigest := ADigest;
end;

function TOpenSslSha3Provider.BackendName: string;
begin
  Result := 'OpenSSL';
end;

function TOpenSslSha3Provider.Resolve(
  const Options: TPadesSignerOptions): TPadesResolvedSigner;
begin
  Result := ResolveSslSignerForDigest(Options, FDigest);
end;

procedure SignSha3OnAnyWindows(Pdf: TPdf; Dest: TStream;
  const Opts: TPadesSignOptions; const KeyPassword: AnsiString);
var
  Provider: IPadesSignerProvider;
  Chain: TPadesChainCertArray;
  SslOpts: TPadesSignOptions;
begin
  if WinSha3DigestAvailable then
  begin
    Pdf.SignPadesToStreamForDigest(Dest, Opts, cdaSha3_256);   // chemin CNG
    Exit;
  end;

  Chain := nil;
  ConfigureSslSigningCredentials(
    TFile.ReadAllBytes('C:\Signing\signer-key.pem'),
    TFile.ReadAllBytes('C:\Signing\signer.der'), Chain, KeyPassword);
  if not SslSha3SigningAvailable then
    raise Exception.Create('No SHA-3 in loaded OpenSSL ' + SslLibraryVersion);

  SslOpts := Opts;
  SslOpts.CertificateThumbprint := '';   // OpenSSL signe avec le certificat configuré
  Provider := TOpenSslSha3Provider.Create(cdaSha3_256);
  ConfigurePadesSignerProvider(Provider);
  try
    Pdf.SignPadesToStreamForDigest(Dest, SslOpts, cdaSha3_256);
  finally
    ConfigurePadesSignerProvider(nil);
  end;
end;

Comment un signataire distant ou HSM apprend-il le condensat ?

La signature distante partage le travail en deux. PreparePadesRemoteSignature écrit un PDF avec un emplacement /Contents vide plus un TPadesRemoteSigningRequest, et le détenteur de la clé renvoie plus tard un CMS détaché, souvent depuis un autre processus ou une autre machine, à travers la session de préparation et de complétion utilisée pour les clés HSM et cloud. Côté préparation, vous posez TPadesRemoteSignOptions.DigestAlgorithm, disons à cdaSha3_384, et la requête enregistre le choix dans TPadesRemoteSigningRequest.DigestAlgorithm à côté de DocumentDigest, la valeur que le messageDigest du CMS doit porter. À partir de là, la requête fait autorité. CompletePadesRemoteSignature recalcule le condensat /ByteRange avec l'algorithme stocké dans la requête et ne consulte plus jamais les options ; sinon, un changement de configuration entre préparation et complétion jugerait une vieille requête avec un algorithme nouveau. Un service de signature bâti sur le composant devrait prendre l'algorithme au même endroit :

uses
  System.SysUtils, System.Classes,
  FPdfCms, FPdfCrypto, FPdfCryptoSsl, FPdfPades,
  FPdfPadesRemote;   // lie l'implémentation Enterprise

function SignRemoteRequest(const RequestFile: string;
  Padding: TPadesPaddingScheme): TBytes;
var
  Stream: TFileStream;
  Request: TPadesRemoteSigningRequest;
  SignerOpts: TPadesSignerOptions;
  Resolved: TPadesResolvedSigner;
begin
  Stream := TFileStream.Create(RequestFile, fmOpenRead or fmShareDenyWrite);
  try
    Request := LoadPadesRemoteSigningRequest(Stream);
  finally
    Stream.Free;
  end;

  SignerOpts := TPadesSignerOptions.Default;   // empreinte vide pour OpenSSL
  SignerOpts.PaddingScheme := Padding;
  // La requête décide du condensat ; pas la configuration de ce service
  Resolved := ResolveSslSignerForDigest(SignerOpts, Request.DigestAlgorithm);
  Result := BuildSignedDataEx(Request.DocumentDigest, Resolved.Signer,
    Resolved.SignerCertDer, Resolved.ChainCertDer, TCmsBuildOptions.Default);
end;

À l'intérieur de la requête, la longueur du condensat est la seule preuve de l'algorithme. Elle sert aussi d'empreinte. Avant de lire le moindre flux, CompletePadesRemoteSignature compare Length(DocumentDigest) avec CmsDigestByteLength(DigestAlgorithm), si bien qu'un condensat tronqué ou un algorithme réétiqueté échoue à ce moment et que le flux de destination n'est jamais touché. La longueur seule ne peut pas distinguer SHA3-256 de SHA-256, tous deux à 32 octets, c'est pourquoi la valeur du condensat est aussi comparée au messageDigest à l'intérieur du CMS renvoyé. Le PDF préparé, en revanche, est identique à l'octet près pour SHA-256 et SHA3-384 : l'emplacement réservé ne contient que des nombres /ByteRange et aucun condensat. Des fichiers identiques se lisent facilement à tort comme un réglage ignoré. La signature distante est livrée dans l'édition Enterprise via l'unité FPdfPadesRemote

La variante est rarement la décision difficile ; pour une clé RSA c'est d'ordinaire cdaSha3_256, et ce qui échoue en production, c'est un hôte dont le CNG ou le libcrypto ne peut pas la produire, c'est pourquoi le contrôle de disponibilité se place au démarrage de l'application dans tout déploiement du PDFium Component pour Delphi