Article technique

Signer un PDF en PAdES B-B avec PDFium en Delphi

PDFium Component signe un PDF avec une signature numérique PAdES B-B via sa méthode SignPades : il charge le document, calcule le condensat de la plage d'octets signée, construit une structure CMS CAdES et ajoute la signature sous forme de mise à jour incrémentale. Le backend cryptographique fonctionne uniquement sous Windows, alors protégez chaque appel avec PadesCryptoAvailable avant de signer

La situation est familière. Un PDF de contrat arrive sur votre bureau, le service juridique veut le signer numériquement avant l'envoi, et vous vous tournez vers la même version de PDFium que vous utilisez déjà pour afficher et inspecter des documents, pour découvrir que PDFium ne sait pas du tout écrire une signature. Son API de signature est strictement en lecture seule. PDFium Component comble cette lacune en prenant en charge tout le pipeline de signature en Pascal, de la fonction de hachage jusqu'à l'injection au niveau des octets, et cet article parcourt ce pipeline de bout en bout

Pourquoi PDFium ne peut-il pas écrire une signature numérique ?

PDFium expose les signatures comme des objets en lecture seule et ne propose rien pour en créer une. La famille FPDFSignatureObj_* permet d'énumérer une signature existante, de lire son /Contents et d'inspecter son /ByteRange, mais il n'existe aucune contrepartie qui construise un dictionnaire de signature, réserve un emplacement /Contents ou écrive une plage d'octets ; l'enregistrement incrémental existe (FPDF_SaveAsCopy avec FPDF_INCREMENTAL) mais ne comporte aucun point d'accroche pour la signature. Tout composant qui signe un PDF au-dessus de PDFium doit donc générer lui-même chaque octet de signature, et c'est pourquoi PDFium Component construit cette mécanique à partir de trois unités en Pascal pur. FPC 3.2.2 fournit md5 et sha1 mais aucun SHA-2, et l'API SHA-256 de System.Hash de Delphi n'est pas compatible au niveau source avec FPC, donc FPdfSha256 est une implémentation autonome conforme à FIPS 180-4 qui maintient tous les chemins de code CMS sur un seul type TSHA256Digest sans branchement par compilateur. FPdfAsn1 fournit l'encodeur et le lecteur DER dont les structures CMS ont besoin, et FPdfCms assemble le SignedData CAdES par-dessus les deux

Comment signer numériquement un PDF en Delphi ?

Chargez le document, puis appelez SignPades avec une empreinte de certificat. PDFium Component résout cette empreinte dans le magasin de certificats « MY » de l'utilisateur courant, récupère le certificat correspondant et sa clé privée, et écrit une copie signée à l'emplacement que vous indiquez

Schéma du pipeline de signature PAdES B-B en Delphi : PDFium Component teste le backend Windows CNG, calcule le condensat de la plage d'octets signée, construit le CMS CAdES et ajoute une mise à jour incrémentale
PDFium Component prend en charge tout le pipeline PAdES B-B, du test de la plateforme au condensat SHA-256 et à la construction du CMS jusqu'à l'ajout incrémental
uses
  PDFium, FPdfCrypto;

procedure SignContract(const AThumbprint: string);
var
  Pdf: TPdf;
begin
  if not PadesCryptoAvailable then
    raise Exception.Create('PAdES signing requires the Windows CNG backend');

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';   // le document à signer
    Pdf.Active := True;
    // Deuxième argument : empreinte SHA-1 d'un certificat du magasin
    // "MY" de l'utilisateur courant. Premier argument : destination de la copie signée.
    if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

PadesCryptoAvailable est le test que vous vérifiez en premier, à chaque fois. Sous Windows il renvoie True et le backend crypt32/ncrypt est actif ; sur toute autre plateforme il renvoie False et un appel de signature déclencherait EPadesCrypto. Traiter ce garde-fou comme obligatoire évite qu'une compilation Linux ou macOS échoue à l'exécution sur un chemin qui ne peut pas fonctionner là. L'empreinte elle-même est le condensat SHA-1 du certificat, la valeur que le gestionnaire de certificats de Windows affiche dans son onglet Détails, et elle désigne un signataire précis sans jamais placer de matériel de clé dans votre code source

Ce qui entre dans le CMS : attributs signés et RFC 5652

Une signature de référence PAdES n'est pas une signature RSA brute sur le fichier ; c'est une structure CMS SignedData CAdES portant un ensemble obligatoire d'attributs signés, et FPdfCms.BuildSignedData émet exactement cet ensemble : content-type, message-digest et signing-certificate-v2, l'attribut ESS qui lie la signature au certificat du signataire par condensat. Un détail met en échec presque toutes les implémentations CMS artisanales. La RFC 5652 §5.4 exige que le condensat des attributs signés soit calculé sur l'encodage DER SET OF, balise 0x31, alors que ces mêmes attributs voyagent à l'intérieur de SignerInfo sous la balise IMPLICIT [0], 0xA0. PDFium Component encode l'ensemble d'attributs une seule fois, calcule le condensat de la forme 0x31, puis réécrit uniquement l'octet de balise de tête en 0xA0 pour l'émission, de sorte qu'un seul tampon remplit les deux rôles sans second parcours de l'arbre

Schéma du balisage des attributs signés dans un CMS PAdES construit par PDFium Component en Delphi : la forme SET OF 0x31 sert au condensat, puis seul l'octet de tête devient 0xA0 dans SignerInfo
La RFC 5652 §5.4 calcule le condensat de l'encodage SET OF balisé 0x31, tandis que les mêmes octets d'attributs voyagent dans SignerInfo sous la balise IMPLICIT [0] 0xA0
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // signataire dans le magasin MY
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // largeur hexadécimale de /Contents

    if not Pdf.SignPades('contract-signed.pdf', Opts) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

La surcharge à options ajoute les métadonnées du dictionnaire de signature définies par la norme ISO 32000-1 §12.8.1 : Reason, Location, ContactInfo et Name, toutes facultatives et toutes écrites dans le dictionnaire de valeur de signature. Une contrainte est facile à enfreindre. Si vous définissez CommitmentTypeOid pour ajouter un attribut signé CAdES commitment-type-indication, ne définissez pas aussi Reason ; la norme ETSI EN 319 142-1 §6.3 interdit de porter les deux, car elles expriment la même intention par des moyens différents

Comment le ByteRange et l'emplacement /Contents fonctionnent-ils ensemble ?

Une signature doit couvrir tout le fichier sauf les octets qui contiennent la signature elle-même, et PAdES résout cette circularité par un espace réservé de largeur fixe que SignPadesBytes gère avec précision. Il réserve une chaîne hexadécimale /Contents de ContentsSize octets (16384 par défaut, confortablement plus grande qu'un SignedData CMS typique), sérialise la mise à jour incrémentale pour localiser le décalage exact de l'emplacement, puis calcule le /ByteRange comme deux intervalles qui encadrent cet emplacement, tout ce qui précède le délimiteur ouvrant de la chaîne hexadécimale et tout ce qui suit son délimiteur fermant. Le SHA-256 ne porte que sur ces deux intervalles. Le CMS terminé est encodé en hexadécimal dans l'emplacement réservé, complété par des zéros jusqu'à la largeur fixe, et la mise à jour de la table de références croisées est ajoutée. Comme la largeur est fixée d'avance, remplir l'emplacement ne décale aucun octet en aval, ce qui est précisément la raison pour laquelle la plage d'octets reste valide ; les octets du document d'origine sont conservés à l'identique, de sorte qu'une signature antérieure sur le même fichier survit intacte, exactement comme l'exige la signature incrémentale de la norme ISO 32000-1 §12.8.1

Schéma de la disposition du ByteRange d'un PDF signé avec PDFium Component en Delphi : deux intervalles hachés encadrent l'emplacement hexadécimal Contents réservé et la mise à jour de références croisées ajoutée
Les deux intervalles du ByteRange encadrent l'emplacement hexadécimal de largeur fixe, donc remplir la signature ne décale jamais un octet déjà compté par le condensat

Le backend Windows CNG et ses limites

PDFium Component signe uniquement sous Windows, et cette limite est délibérée. FPdfCryptoWin lie crypt32.dll et ncrypt.dll dynamiquement, sans ajouter de dépendance DLL à la compilation, et la chaîne de signature est du CNG standard : ouvrir le magasin MY, trouver le certificat par condensat, obtenir le descripteur de sa clé privée via CryptAcquireCertificatePrivateKey, puis appeler NCryptSignHash. RSA avec PKCS#1 v1.5, RSA-PSS et ECDSA sont tous pris en charge. ECDSA demande une correction que les autres n'exigent pas, car NCryptSignHash renvoie la paire r et s brute au format IEEE P1363 alors que CMS attend une SEQUENCE DER ECDSA-Sig-Value, donc le backend la réencode selon la RFC 5480

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // pas de backend de signature sur cette plateforme

  Opts := TPadesSignOptions.Default;
  Opts.CertificateThumbprint := ReadThumbprintFromConfig;

  Pdf := TPdf.Create(nil);
  Output := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.SignPadesToStream(Output, Opts);
  finally
    Output.Free;
    Pdf.Free;
  end;
end;

La conséquence pratique est que la clé privée doit résider dans le magasin de certificats de Windows. Un certificat conservé dans un fichier PFX ne fonctionne qu'après importation dans le magasin de l'utilisateur courant, moment à partir duquel son empreinte est la valeur que vous passez à SignPades. Cette version ne propose ni chemin PKCS#11 ou HSM ni backend de fichier de clé logicielle, donc quand PadesCryptoAvailable renvoie False il n'y a tout simplement aucune signature possible sur cette machine

Là où PAdES B-B s'arrête

PAdES B-B est le niveau de référence, le plancher des quatre niveaux PAdES : il prouve qui a signé et que les octets n'ont pas changé depuis, et rien de plus. Une signature B-B ne porte aucun horodatage de confiance, donc elle ne peut pas prouver quand la signature a eu lieu, et elle n'intègre aucune donnée de révocation, donc un vérificateur, des années plus tard, doit récupérer lui-même la chaîne de certificats et son état. Ces manques sont précisément ce que comblent les niveaux supérieurs. Quand il vous faut une date de signature qu'un auditeur acceptera, ajouter un horodatage RFC 3161 et un DSS pour la validation à long terme fait passer la signature en B-T et au-delà ; quand vous voulez relire une signature terminée et confirmer le niveau qu'elle a atteint, inspecter une signature PDF et son niveau PAdES est l'outil complémentaire ; et avant même de signer quoi que ce soit, auditer les risques de sécurité d'un PDF vous dit ce sur quoi vous vous apprêtez à apposer votre nom

Les méthodes SignPades présentées ici sont livrées avec PDFium Component pour Delphi et C++Builder, aux côtés de l'inspection de signature en lecture seule que PDFium fournit d'origine