Article technique

Signature CSC à distance HotPDF : PDF signés cloud en Delphi

Une requête signatures/signHash vers un service de signature à distance peut expirer après que le HSM a déjà produit la signature, et le client ne peut pas dire de quel côté de cette ligne il est tombé. Relancez aveuglément et une approbation peut donner deux signatures sur le même contrat. Refusez de relancer et un lot nocturne meurt sur un seul 503. Signez quarante factures un haché à la fois et le signataire tape quarante mots de passe à usage unique

Toute intégration Cloud Signature Consortium (CSC) doit résoudre les deux. Les fournisseurs de services de confiance qui émettent des clés qualifiées distribuent de plus en plus un point d’accès API CSC et un client OAuth au lieu d’un fichier PFX ou d’un token USB, si bien qu’il n’y a rien à importer dans un magasin local comme sait le faire la signature depuis le magasin de certificats Windows via CNG et CAPI. La clé reste dans le HSM du fournisseur, et chaque signature est un aller-retour en HTTPS

Pourquoi HotPDF laisse-t-il HTTP à votre application ?

HotPDF signe via un service CSC distant avec THPDFCSCSignatureProvider, un fournisseur de signatures qui joue le protocole CSC v2 (credential info, autorisation, signatures/signHash, interrogation) pendant que votre code Delphi détient le transport HTTP et le token OAuth. Proxys, TLS pinning, certificats clients, coffres à tokens et journalisation des requêtes vivent tous dans la couche HTTP, et ils diffèrent à chaque déploiement

Pour chaque requête, le provider bâtit une THPDFCSCTransportRequest et appelle votre fonction THPDFCSCTransport. L’enregistrement porte l’URL complète, un en-tête bearer Authorization prêt, le Body JSON, une IdempotencyKey, l’OperationIdentifier et le numéro d’Attempt. Votre fonction remplit une THPDFCSCTransportResponse avec StatusCode, Body et RetryAfterMS, puis renvoie ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure ou ctsCancelled

Schéma de la frontière de transport CSC HotPDF : THPDFCSCSignatureProvider orchestre le protocole et remet à votre code un THPDFCSCTransportRequest avec une méthode POST, l'URL complète, un en-tête Authorization bearer prêt, le corps JSON, un IdempotencyKey et le numéro de tentative, et vous renvoyez StatusCode, Body, RetryAfterMS plus l'une des quatre valeurs de statut cts pendant que la clé ne quitte jamais le HSM
Le provider classe lui-même les codes de statut, si bien qu'un transport qui transforme un 503 répondu en échec permanent désactive en silence la logique de relance, tandis que proxys et politique TLS restent dans du code à vous

Un transport bâti sur le THTTPClient de la RTL tient en une quarantaine de lignes. Le corps part en octets bruts, si bien que le JSON ne traverse jamais une conversion de page de codes :

uses
  System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
  HPDFSignatureProvider, HPDFCSCSignatureProvider;

function MakeCSCTransport(Client: THTTPClient): THPDFCSCTransport;
begin
  Result :=
    function(const Request: THPDFCSCTransportRequest;
      out Response: THPDFCSCTransportResponse): THPDFCSCTransportStatus
    var
      Body, Reply: TMemoryStream;
      Headers: TNetHeaders;
      HttpResp: IHTTPResponse;
    begin
      Response := Default(THPDFCSCTransportResponse);
      Body := TMemoryStream.Create;
      Reply := TMemoryStream.Create;
      try
        if Request.Body <> '' then
          Body.WriteBuffer(Request.Body[1], Length(Request.Body));
        Body.Position := 0;
        Headers := [TNameValuePair.Create('Authorization', string(Request.Authorization)),
                    TNameValuePair.Create('Content-Type', string(Request.ContentType))];
        if Request.IdempotencyKey <> '' then   // nom d’en-tête tel que votre service le documente
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // ennui DNS, TLS ou socket : relançable
        end;
        Response.StatusCode := HttpResp.StatusCode; // laissez 429 et 503 passer intacts
        SetLength(Response.Body, Reply.Size);
        if Reply.Size > 0 then
          Move(Reply.Memory^, Response.Body[1], Reply.Size);
        // Retry-After en secondes ; une valeur de date HTTP retombe à 0 ici
        Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
        Result := ctsSuccess;
      finally
        Reply.Free;
        Body.Free;
      end;
    end;
end;

La seule règle à retenir : renvoyez ctsSuccess chaque fois qu’un serveur a répondu, même avec un 503. Le provider classe lui-même les codes de statut, et un transport qui associe un 429 à ctsPermanentFailure éteint la logique de relance décrite plus bas. Les corps plus gros que MaxResponseBytes (2 Mo par défaut) sont rejetés avec spsMalformed quoi que renvoie le transport

Le constructeur est strict dans l’autre sens. Il lève EHPDFCSCSignatureProviderError quand le transport manque, que ServiceBaseURL ou CredentialID est vide, qu’aucun AccessToken ni callback de token n’est fourni, qu’un budget est hors plage, ou que l’URL n’est pas en HTTPS. Un http:// simple ne passe qu’avec AllowInsecureHTTP activé, et ce drapeau appartient à un banc de test pointé vers un service simulé local

Qu’est-ce que le SAD, et pourquoi est-il à usage unique ?

THPDFCSCSignatureProvider traite la Signature Activation Data (SAD) comme à usage unique : elle est effacée de l’état du provider dès que signatures/signHash est accepté, même quand la signature elle-même arrive plus tard par interrogation asynchrone. La SAD est la preuve du service que le signataire a approuvé ces hachés précis. Une qui traîne en mémoire est une autorisation qui attend de être dépensée sur le mauvais document

Avec les défauts de THPDFCSCOptions.Default (RequireSAD et AutoAuthorize tous deux à True), le provider charge credentials/info une fois, demande à votre THPDFCSCAuthenticationCallback les valeurs authData qu’exige le bloc auth du credential, d’habitude un OTP ou un PIN, et poste credentials/authorize. Une réponse 200 porte la SAD directement. Une 202 porte un handle, que le provider interroge via credentials/authorizeCheck jusqu’à MaxPollAttempts (60) fois à PollIntervalMS (250 ms) sauf si le service envoie Retry-After. Le callback peut renvoyer au plus 32 valeurs, chacune avec un ID non vide d’au plus 256 octets et une valeur d’au plus 4 096 octets

L’effacement arrive après l’acceptation. Si le réseau tombe avant que signHash ne soit accepté, une SAD obtenue automatiquement reste en place, toujours liée au même lot, si bien que la relance part sans second appel d’OTP. Le signataire a approuvé une fois ; le provider dépense cette approbation une fois

Schéma du cycle de vie SAD HotPDF : avec RequireSAD et AutoAuthorize, le provider charge credentials/info une fois, demande les valeurs OTP ou PIN au callback d'authentification, poste credentials/authorize, interroge credentials/authorizeCheck jusqu'à 60 fois à 250 ms quand la réponse est 202, et efface la Signature Activation Data au moment où signatures/signHash est accepté, en gardant une SAD obtenue quand le réseau a lâché avant l'acceptation
Une SAD qui traîne en mémoire est une autorisation qui attend d'être dépensée sur le mauvais document, et une SAD pré-réglée passée via les options ne sert que pour une requête à hash unique
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, mode async, 2 relances
  Options.ServiceBaseURL := 'https://csc.example.com/csc/v2';
  Options.CredentialID := 'contracts-signing-01';
  Options.ClientData := 'invoice-run-2026-09';

  Provider := THPDFCSCSignatureProvider.Create(Options, MakeCSCTransport(HttpClient),
    function(ForceRefresh: Boolean; const OperationIdentifier: AnsiString;
      out AccessToken: AnsiString; out ExpiresAtUTC: TDateTime): THPDFSignatureProviderStatus
    begin
      // TokenVault est votre propre client OAuth ; ForceRefresh vaut True après un 401
      if not TokenVault.Acquire(ForceRefresh, AccessToken, ExpiresAtUTC) then
        Exit(spsProviderError);
      Result := spsValid;
    end,
    function(const CredentialID, CredentialInfoJSON, OperationIdentifier: AnsiString;
      out Values: THPDFCSCAuthenticationValues): THPDFSignatureProviderStatus
    var
      Otp: string;
    begin
      if not AskSignerForOtp(Otp) then   // votre propre interface
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

Le callback de token se déclenche quand le token en cache manque ou se trouve à TokenRefreshSkewSeconds (30) de l’expiration que vous avez rapportée ; un ExpiresAtUTC à zéro signifie que le token est réutilisé jusqu’à ce qu’un 401 force un rafraîchissement

Une SAD que vous passez vous-même via Options.SAD se comporte différemment, et à dessein. Rien ne dit au provider quels hachés cette SAD couvre, si bien qu’une SAD pré-posée ne sert que pour une requête à haché unique. Pour un lot multi-hachés avec AutoAuthorize activé, il jette la valeur pré-posée et autorise le lot ; avec AutoAuthorize désactivé il échoue avec « CSC SAD is not pinned to the requested hash batch » au lieu de deviner. Les services qui autorisent par le seul scope OAuth et n’émettent jamais de SAD fonctionnent avec RequireSAD à False, auquel cas le membre SAD est laissé dehors du corps signHash

Signer quarante factures avec un seul appel d’OTP

SignHashBatch envoie un seul credentials/authorize et un seul signatures/signHash pour jusqu’à MaxBatchSignatures (64) condensés, et bâtit les deux corps depuis le même tableau, si bien que numSignatures, l’ordre des hashes et hashAlgorithmOID concordent entre les deux appels. Bouclez la méthode Sign à haché unique quarante fois et vous obtenez quarante autorisations. Envoyez un authorize et un signHash en désaccord et le service peut dépenser la SAD contre le mauvais lot

La validation tourne avant tout trafic réseau. Chaque requête doit être un condensé (sikDigest) de 1 à 1 024 octets avec un OID de condensé, et toutes les requêtes doivent partager un algorithme de signature, un OID de condensé et, pour RSASSA-PSS, une longueur de sel. Un lot multi-hachés charge aussi credentials/info et renvoie spsUnsupported quand la valeur multisign du credential est plus petite que la taille du lot. L’autorisation est ensuite liée à une empreinte SHA-256 sensible à l’ordre de la liste de requêtes, si bien qu’échanger deux hachés fait un lot différent qui réclame une approbation fraîche

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  OpId: AnsiString;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests : valeurs SHA-256 que vous avez calculées
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;   // signAlgo dérivé quand AlgorithmOID est vide
    Requests[I].DigestAlgorithmOID := '2.16.840.1.101.3.4.2.1';
    Requests[I].InputKind := sikDigest;
    Requests[I].Input := Digests[I];
  end;

  // Dérivez l’ID depuis votre propre enregistrement d’exécution, jamais depuis un GUID frais par appel
  OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
  Status := Provider.SignHashBatch(Requests, OpId, Signatures);
  if Status = spsProviderError then
    // Même OpId : un lot terminé revient du cache, un job async en attente
    // reprend son interrogation, une requête non envoyée réutilise la même clé
    Status := Provider.SignHashBatch(Requests, OpId, Signatures);
  if Status <> spsValid then
    raise Exception.CreateFmt('CSC batch failed (HTTP %d): %s',
      [Provider.LastHTTPStatus, Provider.LastError]);
  // Signatures[I] appartient à Digests[I] ; le compte a été contrôlé contre la requête
end;

Pour RSASSA-PSS, le provider ajoute signAlgoParams, une structure RSASSA-PSS-params DER en base64 portant l’algorithme de hachage, MGF1 et la longueur de sel. Le §8.19.4 du X.690 replie les deux premiers arcs d’OID en une valeur (40 × premier + second), et sous la racine 2 un second arc au-delà de 39 le pousse au-delà de 127, où il réclame la forme multi-octets en base 128. Aucun OID SHA-2 n’exerce ce chemin, puisque 2.16 se replie en 96, si bien qu’un encodeur peut passer tous les tests SHA-2 et être faux quand même. Le provider écrit la forme multi-octets et rapporte un OID malformé comme sa propre EHPDFCSCSignatureProviderError plutôt qu’une EConvertError

Pourquoi une requête relancée ne produit-elle pas une seconde signature ?

THPDFCSCSignatureProvider donne à chaque appel relançable une clé d’idempotence déterministe et met en cache les résultats terminés, si bien qu’une relance après une réponse perdue renvoie les signatures d’origine au lieu de demander de nouvelles signatures au HSM. La clé est csc- suivi de l’hex SHA-256 de l’identifiant d’opération et de la phase du protocole, et pour l’autorisation comme pour signHash la phase inclut l’empreinte du lot. Hacher au lieu de tronquer garde la clé à longueur fixe et empêche deux longs identifiants d’opération à préfixe commun d’entrer en collision

La politique de relance dans le chemin de requête partagé est étroite :

  • Un 401 HTTP force exactement un rafraîchissement de token via le callback de token d’accès, et la requête est répétée une fois ; un second 401 est définitif, et sans callback de token le premier l’est
  • Les autres réponses 4xx et ctsPermanentFailure terminent l’appel avec spsProviderError, et l’error_description du service (ou error) atterrit dans LastError
  • 408, 429, 5xx et ctsTemporaryFailure sont relancés jusqu’à RetryLimit (défaut 2), en attendant Retry-After ou RetryBaseDelayMS (100 ms) doublé à chaque tentative, plafonné à MaxRetryAfterMS (5 000 ms)
  • Les attentes tournent par tranches de 25 ms qui vérifient Cancel, pour qu'un utilisateur qui abandonne ne morde pas cinq secondes de back-off
  • signHash n’est relancé que tant que EnableIdempotency est actif ; désactivez-le et un timeout après soumission est définitif, parce que personne ne peut dire si le haché était déjà signé
Diagramme de la politique de relance HotPDF : chaque appel relançable porte une clé d’idempotence csc- déterministe hachée depuis l’identifiant d’opération et la phase, un 401 HTTP force exactement un rafraîchissement de token, les autres réponses 4xx se terminent en spsProviderError, et 408, 429, 5xx ou un échec de transport temporaire relancent jusqu’à RetryLimit de 2 en attendant Retry-After ou un backoff exponentiel plafonné à 5 000 ms
Les lots terminés sont mis en cache par identifiant d’opération, credential et empreinte, et en mode asynchrone le responseID stocké permet à un appel répété de reprendre l’interrogation au lieu de resoumettre le haché

La signature asynchrone (operationMode « A », le défaut, avec un validity_period de AsyncValidityPeriodMS, 60 000 ms) ajoute un garde de plus. Le responseID est stocké avant que le provider ne commence à interroger signatures/signPolling, si bien qu’un appel répété avec le même identifiant d’opération reprend le job en attente au lieu de soumettre le haché encore. Les lots terminés sont mis en cache par instance de provider, jusqu’à MaxOperationCacheEntries (128), et ce cache disparaît après un redémarrage. La clé d’idempotence survit, parce qu’elle est dérivée au lieu d’être aléatoire : un processus relancé qui réutilise son identifiant d’opération envoie la même clé, et le service déduplique ou non dessus fait partie du contrat du service

Tout cela repose sur l’identifiant d’opération, et le paramètre est facile à laisser vide. Passez '' à SignHashBatch et le provider retombe sur l’OperationIdentifier des requêtes, puis sur un nouveau GUID. Un nouveau GUID par appel signifie que votre propre boucle de relance démarre une nouvelle opération à chaque fois : nouvel emplacement de cache, nouvelle clé d’idempotence, et peut-être un second appel d’OTP. Cela signifie aussi que Provider.Cancel n’a rien à cibler, puisque votre code n’a jamais vu le GUID. Dérivez l’identifiant de ce que vous persistez déjà, comme le numéro de lot ou l’ID de document, et gardez EnableIdempotency à son défaut True. Si un service rejette l’en-tête, retirez-le dans le transport au lieu de désactiver l’option

Embarquer une signature CSC dans un PDF

Remettez le provider à HPDFCMSSignPDFStreamWithProvider avec le certificat d’entité finale de GetCertificateChain ; HotPDF bâtit le CMS SignedData et le provider signe le condensé des attributs signés. Le PDF d’entrée a besoin du placeholder /ByteRange et /Contents qu’écrit THPDFPage.AddSignedSignatureField, exactement comme dans le workflow de signature PAdES dans HotPDF, et le modèle de provider est le même que derrière les fournisseurs de signatures enfichables ML-DSA et EdDSA de HotPDF

var
  Chain: THPDFCSCCertificateChain;
  SignOpts: THPDFCMSSignOptions;
  Src, Dst: TFileStream;
begin
  if Provider.RefreshCredentialInfo <> spsValid then
    raise Exception.Create(Provider.LastError);
  Chain := Provider.GetCertificateChain;   // le CSC liste le certificat d'entité finale en premier
  if Length(Chain) = 0 then
    raise Exception.Create('Credential returned no certificate');

  SignOpts := HPDFCMSDefaultOptions(palBaseline_B_B);
  SignOpts.DigestAlgorithm := cmsdaSHA256;
  SignOpts.SignatureScheme := cmsRSAPKCS1v15;

  Src := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
  Dst := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    if not HPDFCMSSignPDFStreamWithProvider(Src, Dst, Chain[0], Provider, '', SignOpts) then
      raise Exception.Create('PDF signing failed');
  finally
    Dst.Free;
    Src.Free;
  end;
end;

Pour le dimensionnement du placeholder, EstimateSignatureSize renvoie EstimatedSignatureBytes quand vous le réglez, sinon la taille du module RSA dérivée de la longueur de clé dans credentials/info. Pour des clés ECDSA il répond spsUnsupported et l’estimateur de placeholder retombe sur le certificat, si bien que régler EstimatedSignatureBytes vous-même donne un résultat prévisible. HPDFCMSSignPDFStreamWithProviderAutoSize rebâtit et re-signe quand un placeholder s’avère trop petit, et seulement pour les providers qui annoncent spcSafeSignRetry. THPDFCSCSignatureProvider l’annonce tant que EnableIdempotency est actif, ce qui est une raison de plus de laisser ce défaut tranquille

Pour PAdES-B-T, TimestampDigest demande au même service un token RFC 3161 via signatures/timestamp, avec un nonce optionnel et un plafond de taille de MaxTimestampBytes (1 Mo)

Décisions de politique qui restent dans votre code

Le provider signe des condensés. Ed25519 et Ed448 en mode pur remettent à un fournisseur de signatures le message entier des attributs signés (sikMessage), et le validateur de lots rejette cela comme malformé, parce que signHash est à base de hachés par définition. Sous Free Pascal, l’unité du provider se compile avec des types de fonctions simples à la place des méthodes anonymes, si bien que SignHashBatch et TimestampDigest y sont appelables ; embarquer le résultat dans un PDF via HPDFCMSSignPDFStreamWithProvider est un chemin Delphi

Le provider laisse aussi la politique à vous. CredentialInfo rapporte KeyStatus, CertificateStatus, AuthorizationMode, SCAL et MultiSignatures, mais une clé désactivée ou un credential SCAL1 est à vous de le rejeter. Contrôlez ces champs juste après RefreshCredentialInfo et avant que le signataire ne voie jamais un appel d’OTP ; un refus du service après que le signataire a tapé un code est une pire expérience qu’une erreur claire d’entrée

Une instance de provider ne lance qu’un lot à la fois, parce que SignHashBatch, RefreshCredentialInfo et TimestampDigest partagent un verrou pour que deux threads ne se battent pas pour une SAD. Le débit vient de lots plus gros, jusqu’à la limite multisign du credential. Que la signature résultante compte comme qualifiée est décidé par le service de confiance et le credential qu’il a émis

Le provider CSC se livre aux côtés des constructeurs CMS et PAdES et des fournisseurs de signatures locaux et PKCS#11 dans le composant HotPDF Delphi PDF, si bien que la signature à distance, par token et par fichier alimentent toutes les mêmes appels de signature PDF