Article technique

Atelier de conformité et de signature en Delphi

Un atelier qui enchaîne la validation de conformité à la signature numérique doit coordonner quatre étapes, dans cet ordre, et les garder attachées à un seul jeu d'octets d'un bout à l'autre. Il exécute un preflight PDF/A ou PDF/UA. Il applique les corrections exigées par les constats et enregistre une révision corrigée. Il signe cette révision précise. Puis il relit le fichier signé et confirme que la signature le couvre réellement. L'ordre n'a rien de cosmétique. Sautez la relecture et vous faites confiance à votre propre chemin d'écriture ; laissez le preflight porter sur la mauvaise révision et votre rapport de conformité décrit un fichier que vous n'avez jamais livré

Ce que la plupart des pipelines maison ratent, c'est la couture entre validation et signature. Exécutez-les comme deux outils séparés avec une passe de remédiation entre les deux et au moins trois révisions distinctes du fichier viennent à exister, chacune avec ses propres octets. Le rapport de preflight que vous tendez à un auditeur en décrit une. La signature en fige une autre. Rien dans le fichier n'affirme qu'il s'agit de la même révision, et souvent ce n'en est pas une. PDF Library for Delphi, la losLab PDF Developer Library pour Delphi et C++Builder, place le preflight et la signature PAdES derrière une seule façade de classe, si bien que toute la séquence peut tenir dans un processus qui ne perd jamais de vue les octets dont il parle. Chaque appel ci-dessous existe dans la bibliothèque aujourd'hui, et chaque piège signalé à côté aussi

Diagramme d'un atelier Delphi de conformité et de signature où les étapes preflight, remédiation, signature PAdES et audit ByteRange consignent chacune un SHA-256 sur la révision exacte qu'elles touchent
Les empreintes consignées à côté de chaque enregistrement rattachent le rapport de preflight, la signature PAdES et l'audit à une seule et même révision

Trois révisions du même document, et comment l'écart se creuse

Comptez les enregistrements. L'original arrive de l'amont. La passe de remédiation le charge, active un mode de conformité et écrit une révision corrigée. La passe de signature ajoute une signature sous forme de mise à jour incrémentielle, ce qui fait une troisième écriture. Trois enregistrements, trois dispositions d'octets, et un rapport de preflight ne veut rien dire tant qu'il ne nomme pas laquelle des trois il couvre. Un SHA-256 du fichier, consigné à côté de chaque exécution de preflight et de chaque signature, est l'ancrage bon marché qui vous permet de prouver que la révision validée est bien la révision signée

Un comportement de la bibliothèque resserre encore cette discipline. Les corrections de conformité demandées par SetPDFAMode ou SetPDFUAMode ne prennent pas effet au moment de l'appel. Elles sont appliquées pendant l'enregistrement. Les réparations automatiques, comme le forçage des indicateurs d'impression des annotations ou l'attribution d'un ordre de tabulation PDF/UA, atterrissent dans le fichier de sortie et nulle part ailleurs ; un contrôle exécuté sur le document que vous venez de "corriger" en mémoire ne vous dit donc rien des octets destinés au signataire. Enregistrez d'abord, puis passez le fichier enregistré au preflight. L'état en mémoire est un brouillon ; seul le fichier sur le disque est réel

Preflight depuis le disque, et le zéro qui signifie deux choses

Le point d'entrée preflight de l'API plate est CheckFileCompliance(FileName, Password, ComplianceTest, Options). Le test 1 sélectionne PDF/A (ISO 19005), le test 2 sélectionne PDF/UA (ISO 14289). Il ouvre le fichier via le lecteur en flux de la bibliothèque, il n'y a donc pas besoin d'un LoadFromFile préalable, et il renvoie un handle de liste de chaînes portant un constat par entrée :

var
  PDF: TPDFlib;
  ListID, I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0);  // 1 = PDF/A
    if ListID = 0 then
    begin
      if PDF.LastErrorCode <> 0 then
        raise Exception.Create('Preflight could not read the file')
      else
        Writeln('No PDF/A findings');
    end
    else
    begin
      for I := 0 to PDF.GetStringListCount(ListID) - 1 do
        Writeln(PDF.GetStringListItem(ListID, I));
      PDF.ReleaseStringList(ListID);
    end;
  finally
    PDF.Free;
  end;
end;

Le piège se loge dans la valeur de retour, et c'est le genre de piège qui passe tous les tests du chemin heureux. Zéro signifie "aucun constat". Zéro signifie aussi "le fichier n'a pas pu être ouvert", car l'implémentation renvoie 0 dès que la liste de résultats revient vide, échec de lecture compris. Un atelier qui lit 0 comme un feu vert approuvera allègrement un fichier qu'un autre processus a verrouillé. Associer l'appel à LastErrorCode, comme ci-dessus, est ce qui sépare les deux cas. Le vérificateur ouvre par ailleurs le fichier dans un mode de partage qui refuse l'écriture ; si votre étape de remédiation tient encore un handle d'écriture, le preflight échoue pour une raison qui n'a rien à voir avec la conformité et tout à voir avec un flux que vous avez oublié de libérer

Diagramme de décision montrant comment LastErrorCode sépare les deux significations d'un retour zéro de CheckFileCompliance dans un preflight PDF Delphi
Un zéro renvoyé par CheckFileCompliance ne veut rien dire tant que LastErrorCode ne sépare pas une liste de constats vide d'un fichier que la bibliothèque n'a pas pu ouvrir

Lorsque c'est une personne, et non un pipeline, qui doit lire les constats, CreatePreflightReport les restitue sous forme de rapport lisible. ComparePreflightReports compare deux exécutions, une manière propre de montrer que la remédiation a levé les constats d'origine sans en introduire discrètement de nouveaux

Signer la révision contrôlée avec un SignProcess

Une fois que la révision enregistrée passe le preflight et que son empreinte est consignée, signez ce fichier précis et aucun autre. L'API SignProcess se lit comme un constructeur. Ouvrez un handle de processus, configurez-le ligne par ligne, validez, puis relisez le code de résultat

ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
  raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached');  // signature de base PAdES
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2);                      // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192);              // place pour un horodatage ultérieur
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
  Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);

Deux lignes de cette séquence pèsent plus lourd qu'il n'y paraît. SetSignProcessCustomSubFilter avec ETSI.CAdES.detached choisit une signature PAdES telle que profilée dans ETSI EN 319 142-1 plutôt que la famille héritée adbe.pkcs7.detached, ce qui fait la différence entre une signature qu'un validateur européen accepte et une qu'il signale. SetSignProcessReserveContentsBytes rembourre l'espace réservé /Contents, et la taille choisie ici est une décision sur l'avenir : si un horodatage de signature doit un jour suivre, le CMS agrandi devra tenir dans la place que vous réservez maintenant, car l'espace réservé ne peut plus grandir ensuite sans tout resigner. Réservez généreusement et vous gaspillez quelques kilo-octets. Réservez trop juste et l'étape d'horodatage échouera dans des mois avec un débordement que vous aurez du mal à relier à cette unique ligne

GetSignProcessResult répond par un code, pas par un booléen, et ces codes méritent d'être conservés. 1 vaut succès. 4 signale un mauvais mot de passe PDF, 7 un mauvais mot de passe de certificat, 9 un PFX qui ne porte aucune clé privée, 11 un échec pendant l'application de la signature. Écrasez-les en vrai/faux et vous jetez la seule information qui distingue un cas d'assistance "mot de passe erroné" d'un cas "clé sans partie privée". Journalisez l'entier

Relecture : auditer le fichier que vous venez de produire

Aucun atelier ne devrait faire confiance au chemin qui a écrit le fichier qu'il s'apprête à certifier. La classe d'audit TPDFlibSignDoc rouvre la sortie signée et lit les entrées du dictionnaire de signature directement sur le disque :

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Capturer la taille avant Open : l'objet d'audit garde un verrou de partage sur le fichier
  FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
  SourceSize := FS.Size;
  FS.Free;
  Doc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
    Doc.GetSignatureFieldNames(Names);
    for I := 0 to Names.Count - 1 do
      if Doc.GetSignatureValueObjNum(Names[I]) > 0 then  // > 0 signifie que le champ est signé
      begin
        RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
        GapStart   := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
        TailStart  := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
        TailLen    := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
        if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
          Writeln(Names[I], ': signature covers the file to EOF')
        else
          Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
      end;
    Doc.Close;
  finally
    Names.Free;
    Doc.Free;
  end;
end;

Les arguments ValueKey correspondent à des entrées du dictionnaire. La clé 0 renvoie le CMS brut de /Contents, les clés 2 et 3 les noms /Filter et /SubFilter, et 11 à 14 les quatre nombres du ByteRange. Les valeurs textuelles reviennent en revanche par GetSignatureTextValueByName : la clé 0 donne l'heure de signature déclarée, et la clé 5 distingue un Sig ordinaire d'un DocTimeStamp, ce qui compte dès qu'un document porte les deux

La capture de la taille du fichier en tête de cet exemple est porteuse, ce n'est pas du ménage. TPDFlibSignDoc.Open maintient le fichier sous un verrou de partage restrictif pendant toute sa durée de vie ; tout ce qui a besoin des octets bruts, comme hacher la plage signée ou recalculer l'empreinte du CMS, doit donc lire le fichier avant l'appel à Open. La démo SigningWorkbench de la bibliothèque lit tout le fichier en mémoire d'abord pour exactement cette raison, et un atelier qui ignore cet ordonnancement échoue par intermittence, sur la machine qui perd la course ce jour-là

L'arithmétique ByteRange qui prouve la couverture

Un fichier sain à signature unique présente un ByteRange de la forme [0 a b c] : la couverture démarre à l'offset 0, saute l'espace réservé hexadécimal /Contents entre a et b, puis reprend jusqu'à l'octet b+c. Quand b+c égale la taille du fichier, la signature couvre tout jusqu'à la fin du fichier, et c'est le résultat recherché. Quand elle reste en deçà, quelqu'un a ajouté une mise à jour incrémentielle après l'écriture de la signature. C'est parfaitement légitime au regard de l'ISO 32000-1§12.8, puisque les remplissages de formulaire ultérieurs, une deuxième signature et un dictionnaire DSS arrivent tous exactement de cette manière. C'est aussi précisément le fait qu'une piste d'audit devrait consigner au moment de la signature plutôt que reconstituer sous pression pendant un litige

PDF Library for Delphi : anatomie ByteRange d'un PDF signé montrant le trou de l'espace réservé Contents, un cas de couverture complète et un cas de mise à jour incrémentielle ajoutée
Un ByteRange de 0 a b c ne couvre le fichier que si b + c atteint la fin du fichier, et l'audit consigne donc toute mise à jour incrémentielle ajoutée après la signature

Surveillez la largeur des entiers pendant ce calcul. Le GetSignProcessByteRange de l'API plate rend un Integer 32 bits alors que les valeurs sous-jacentes sont des Int64 ; sur un fichier au-delà de 2 Go, cet accesseur plat tronque donc en silence. Passez par le TPDFlibSigner.GetByteRange de la couche classe, qui renvoie un Int64, ou extrayez les valeurs de GetSignatureValueByName comme le fait le code d'audit ci-dessus

Ce que la bibliothèque vous laisse faire

Mieux vaut découvrir deux limites au moment de la conception que dans le sprint final. L'API plate TPDFlib ne porte aucun enrobage de vérification de signature. La vérification cryptographique vit une couche plus bas, dans TPDFlibSignatureVerifier, dont VerifySignature répond valide, invalide ou inconnu. Il n'existe pas non plus de client HTTP intégré pour les autorités d'horodatage RFC 3161. La bibliothèque calcule l'empreinte à soumettre et réintègre le CMS augmenté une fois le jeton revenu, mais l'aller-retour réseau vers la TSA reste à votre charge. Les deux sont simples à envelopper et franchement pénibles à découvrir manquants la semaine avant une livraison, alors prévoyez-les dès la première esquisse

Une question de conformité mérite d'être tranchée nettement, car elle décide de l'emplacement de la dernière barrière : ajouter une signature casse-t-il le PDF/A ? Pas en soi. La signature arrive sous forme de mise à jour incrémentielle, et l'ISO 19005-2 et ses successeurs autorisent explicitement les documents signés. Le piège est l'apparence de la signature, qui obéit aux mêmes règles que n'importe quel autre contenu de page, polices incorporées et absence de couleur dépendante du périphérique comprises. La dernière barrière de l'atelier est donc une exécution de preflight de plus, cette fois sur la sortie signée. Traitez CheckFileCompliance comme le contrôle rapide intégré au pipeline et vérifiez malgré tout les versions candidates avec un outil indépendant tel que veraPDF, puisque les validateurs implémentent des jeux de règles qui se recouvrent sans être identiques ; quand les deux divergent, le texte du constat nomme en général la clause à aller lire

Un point de séquencement découle de tout cela. Signature et horodatage ne forment pas une seule passe : la signature de base est écrite d'abord, puis un processus d'horodatage distinct enrichit le CMS à l'intérieur de l'espace /Contents réservé, ce qui explique exactement pourquoi la ligne de réservation d'octets plus haut pesait si lourd. Pour les couches d'horodatage et de validation à long terme qui s'appuient sur cet atelier, le guide de signature et de validation PAdES conduit la signature de la ligne de base jusqu'à B-LT, et la moitié preflight est approfondie dans le guide de preflight PDF/A et PDF/UA. La documentation complète de l'API et les téléchargements d'évaluation se trouvent sur la page produit de PDF Library for Delphi