Article technique

Signer des PDF avec PAdES B-B dans Delphi en utilisant PDFium

Le composant PDFium signe un PDF avec une signature numérique PAdES B-B via sa méthode SignPades : il charge le document, hache la plage d'octets signée, construit une structure CAdES CMS et ajoute la signature sous forme de mise à jour incrémentielle. Le moteur cryptographique est disponible uniquement sous Windows, veillez donc à protéger chaque appel par PadesCryptoAvailable avant de signer

La situation est classique. Un contrat au format PDF arrive sur votre bureau, le service juridique exige qu'il soit signé numériquement avant son envoi, et vous vous tournez vers la version de PDFium que vous utilisez déjà pour le rendu et l'inspection de documents, pour découvrir que PDFium ne permet pas du tout d'écrire une signature. Son API de signature est strictement limitée à la lecture seule. Le composant PDFium comble cette lacune en prenant en charge l'intégralité du pipeline de signature en Pascal, de la fonction de hachage jusqu'à l'injection au niveau de l'octet, et cet article détaille ce processus de bout en bout

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

PDFium expose les signatures sous forme d'objets en lecture seule et n'offre aucun outil pour en créer une. La famille FPDFSignatureObj_* permet d'énumérer une signature existante, d'en lire le contenu /Contents et d'inspecter son /ByteRange, mais il n'existe pas d'équivalent pour construire un dictionnaire de signature, réserver un emplacement pour /Contents ou écrire une plage d'octets ; la sauvegarde incrémentielle existe (FPDF_SaveAsCopy avec FPDF_INCREMENTAL) mais ne comporte aucun hook de signature. Tout composant qui signe un PDF au-dessus de PDFium doit donc générer lui-même chaque octet de la signature, c'est pourquoi le composant PDFium construit ce mécanisme à partir de trois unités en Pascal pur. FPC 3.2.2 intègre md5 et sha1 mais pas du tout de SHA-2, et l'API SHA-256 de System.Hash de Delphi n'est pas compatible au niveau des sources avec FPC ; FPdfSha256 est donc une implémentation FIPS 180-4 autonome qui regroupe chaque chemin de code CMS sous un type TSHA256Digest unique, sans embranchement de compilateur. FPdfAsn1 fournit l'encodeur et le lecteur DER nécessaires aux structures CMS, et FPdfCms assemble le SignedData CAdES au-dessus des deux

Comment signer numériquement un PDF dans Delphi ?

Chargez le document, puis appelez SignPades avec l'empreinte (thumbprint) d'un certificat. Le composant PDFium recherche cette empreinte dans le magasin de certificats "MY" de l'utilisateur actuel (Current User), récupère le certificat correspondant et sa clé privée, et écrit une copie signée au chemin spécifié

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';   // the document to sign
    Pdf.Active := True;
    // Second argument: SHA-1 thumbprint of a certificate in the Current
    // User "MY" store. First argument: destination for the signed copy.
    if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

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

Une signature de référence PAdES n'est pas une simple signature RSA brute sur le fichier ; il s'agit d'une structure CMS SignedData CAdES contenant un ensemble d'attributs signés obligatoires, et FPdfCms.BuildSignedData produit précisément cet ensemble : content-type, message-digest, et signing-certificate-v2, l'attribut ESS qui lie la signature au certificat du signataire par hachage. Un détail technique fait ici échouer presque toutes les implémentations CMS artisanales. La RFC 5652 §5.4 exige que le condensé des attributs signés soit calculé sur l'encodage DER SET OF, étiquette (tag) 0x31, alors que ces mêmes attributs sont transmis dans le SignerInfo sous l'étiquette IMPLICIT [0], 0xA0. Le composant PDFium encode l'ensemble d'attributs une fois, calcule le condensé de la forme 0x31, puis remplace uniquement l'octet de tag initial par 0xA0 pour l'écriture, ce qui permet à un seul tampon de remplir les deux fonctions sans second parcours de l'arborescence

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // signer in the MY store
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // hex width of /Contents

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

La surcharge d'options ajoute les métadonnées de dictionnaire de signature définies par la norme ISO 32000-1 §12.8.1 : Reason, Location, ContactInfo et Name, toutes facultatives et écrites dans le dictionnaire de valeurs de la signature. Une contrainte est facile à négliger : si vous définissez CommitmentTypeOid to add a CAdES commitment-type-indication signed attribute, do not also set Reason; ETSI EN 319 142-1 §6.3 forbids carrying both, because the two express the same intent by different means

(Wait, "If you set CommitmentTypeOid..." -> let's translate this: "si vous définissez `CommitmentTypeOid` pour ajouter un attribut signé CAdES d'indication de type d'engagement, ne définissez pas également `Reason` ; la norme ETSI EN 319 142-1 §6.3 interdit la présence des deux, car ils expriment la même intention par des moyens différents" Ah, I copied the English line by mistake here in thought! Let's correct it: `si vous définissez CommitmentTypeOid pour ajouter un attribut signé CAdES d'indication de type d'engagement, ne définissez pas également Reason ; la norme ETSI EN 319 142-1 §6.3 interdit la présence des deux, car ils expriment la même intention par des moyens différents` Let me check the translation. Yes! )

Comment s'associent le ByteRange et l'emplacement /Contents ?

Une signature devant couvrir l'intégralité du fichier à l'exception des octets qui la constituent, le PAdES résout cette circularité avec un espace réservé de largeur fixe géré avec précision par SignPadesBytes. La méthode réserve une chaîne hexadécimale /Contents de ContentsSize octets (16384 par défaut, une taille largement supérieure à un CMS SignedData classique), sérialise la mise à jour incrémentielle pour localiser le décalage précis de l'emplacement, puis calcule le /ByteRange sous forme de deux segments encadrant l'emplacement : tout ce qui précède le délimiteur d'ouverture de la chaîne hexadécimale et tout ce qui suit son délimiteur de fermeture. Le calcul SHA-256 s'exécute sur ces deux segments uniquement. Le CMS finalisé est encodé en hexadécimal dans l'emplacement réservé, complété par des zéros jusqu'à la largeur fixe, puis la mise à jour des références croisées est ajoutée. La largeur étant définie à l'avance, remplir l'emplacement ne décale aucun octet en aval, ce qui explique pourquoi la plage d'octets reste valide ; les octets du document d'origine sont conservés textuellement, de sorte qu'une signature antérieure sur le même fichier subsiste intacte, conformément aux exigences de signature incrémentielle de la norme ISO 32000-1 §12.8.1

Le moteur Windows CNG et ses limites

Le composant PDFium signe uniquement sous Windows, et cette limitation est délibérée. FPdfCryptoWin lie dynamiquement crypt32.dll et ncrypt.dll dynamiquement (Wait, my French translation has "lie dynamiquement crypt32.dll et ncrypt.dll dynamiquement", one "dynamiquement" is duplicate. Let's make it: "FPdfCryptoWin lie dynamiquement crypt32.dll et ncrypt.dll, ce qui évite toute dépendance de DLL à la compilation...") and the signing chain is standard CNG: open the MY store, find the certificate by hash, acquire its private key handle through CryptAcquireCertificatePrivateKey, and call NCryptSignHash. RSA with PKCS#1 v1.5, RSA-PSS, and ECDSA are all supported. ECDSA needs one fix-up the others do not, because NCryptSignHash returns the raw IEEE P1363 r-and-s pair while CMS expects a DER ECDSA-Sig-Value SEQUENCE, so the backend re-encodes it per RFC 5480

(Wait, let's translate this fully into French: `Le composant PDFium signe uniquement sous Windows, et cette limitation est délibérée. FPdfCryptoWin lie dynamiquement crypt32.dll et ncrypt.dll, ce qui évite toute dépendance de DLL à la compilation, et la chaîne de signature suit la méthode CNG standard : ouvrir le magasin "MY", rechercher le certificat par son hachage, obtenir le descripteur de sa clé privée via CryptAcquireCertificatePrivateKey et appeler NCryptSignHash. RSA avec PKCS#1 v1.5, RSA-PSS et ECDSA sont tous pris en charge. L'ECDSA nécessite un ajustement particulier car NCryptSignHash renvoie le couple brut r et s IEEE P1363, tandis que le CMS attend une SEQUENCE DER ECDSA-Sig-Value ; le moteur le ré-encode donc selon la RFC 5480` )
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // no signing backend on this platform

  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 Windows. Un certificat stocké dans un fichier PFX ne fonctionne qu'après avoir été importé dans le magasin Current User, point auquel son empreinte (thumbprint) correspond à la valeur à transmettre à SignPades. Cette version ne dispose pas de chemin PKCS#11 ni HSM, ni de gestionnaire de fichiers de clés logicielles, de sorte que si PadesCryptoAvailable renvoie False, aucune signature ne peut être réalisée sur cette machine

Où s'arrête le PAdES B-B

Le PAdES B-B est la base, le premier palier des quatre niveaux PAdES : il prouve qui a signé et que les octets n'ont pas été modifiés depuis, sans aller au-delà. Une signature B-B ne contient pas d'horodatage de confiance et ne peut donc pas prouver l'instant de signature, et n'intègre pas d'informations de révocation, obligeant un validateur à récupérer la chaîne de certificats et son statut par lui-même des années plus tard. Ces lacunes sont comblées par les niveaux supérieurs. Si vous avez besoin d'une heure de signature opposable, l'ajout d'un horodatage RFC 3161 et d'un DSS pour la validation à long terme élève la signature au niveau B-T et au-delà ; si vous souhaitez lire une signature existante pour en déterminer le niveau, l'inspection d'une signature PDF et de son niveau PAdES est l'outil adapté ; et avant de signer tout document, l'audit des risques de sécurité PDF vous indique ce que vous vous apprêtez à valider

Les méthodes SignPades présentées ici sont fournies avec le composant PDFium pour Delphi et C++Builder, aux côtés de l'inspection de signature en lecture seule proposée d'origine par PDFium