HotPDF signe des documents PDF avec une clé privée détenue par un service Cloud Signature Consortium (CSC) distant via THPDFCSCSignatureProvider, un provider de signatures qui pilote l'API CSC — credential info, autorisation, signatures/signHash et polling — pendant que votre application Delphi fournit le transport HTTP et le jeton d'accès OAuth. La clé ne quitte jamais le HSM du service
C'est de plus en plus la seule façon d'obtenir une clé de signature qualifiée, tout court. Les prestataires de services de confiance distribuent un endpoint CSC et un client OAuth, pas un fichier PFX ni un token USB, donc il n'y a rien à charger dans un magasin de certificats local comme le fait la signature via le magasin de certificats Windows par CNG et CAPI. L'intégration naïve échoue de façons prévisibles : un appel signHash tombe en timeout et la relance signe deux fois le même contrat, ou un lot de quarante factures déclenche quarante mots de passe à usage unique parce que chaque hash a été autorisé séparément. L'essentiel de ce que fait le provider, c'est se défendre contre ces deux échecs
Pourquoi HotPDF laisse-t-il HTTP à votre application ?
Parce que le transport est exactement là où chaque déploiement diffère. Proxys, TLS pinning, certificats clients, coffres OAuth d'entreprise et politique de journalisation vivent tous dans la couche HTTP, donc THPDFCSCSignatureProvider orchestre l'état du protocole et appelle une fonction THPDFCSCTransport pour chaque requête. Le provider vous remet un THPDFCSCTransportRequest avec Method (toujours POST), l'URL complète bâtie depuis ServiceBaseURL plus le chemin d'endpoint, un en-tête Authorization bearer prêt, ContentType, le Body JSON, un IdempotencyKey, le numéro d'Attempt et MaxResponseBytes. Vous remplissez un THPDFCSCTransportResponse avec StatusCode, Body et RetryAfterMS, et renvoyez l'un de ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure ou ctsCancelled
uses
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: TStringStream;
Reply: TMemoryStream;
Headers: TNetHeaders;
HttpResp: IHTTPResponse;
begin
Response := Default(THPDFCSCTransportResponse);
Body := TStringStream.Create(string(Request.Body), TEncoding.UTF8);
Reply := TMemoryStream.Create;
try
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); // pépin de socket ou de DNS : retentable
end;
Response.StatusCode := HttpResp.StatusCode; // rapporter 503 tel quel, ne pas classer
SetLength(Response.Body, Reply.Size);
if Reply.Size > 0 then
Move(Reply.Memory^, Response.Body[1], Reply.Size);
Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
Result := ctsSuccess;
finally
Reply.Free;
Body.Free;
end;
end;
end;
La règle à retenir : renvoyez ctsSuccess dès qu'un serveur a réellement répondu, même avec un 503. Le provider classe lui-même les codes de statut, et un transport qui transforme un 429 en ctsPermanentFailure désactive en silence la logique de relance décrite plus bas. Le constructeur est strict dans l'autre sens — il lève EHPDFCSCSignatureProviderError quand le transport manque, que CredentialID est vide, que ni AccessToken ni callback de jeton ne sont fournis, qu'un budget est hors bornes, ou que ServiceBaseURL n'est pas en HTTPS. Le http:// simple n'est accepté qu'avec AllowInsecureHTTP, qui a sa place dans un banc de test et nulle part ailleurs
Qu'est-ce que le SAD, et pourquoi HotPDF le jette-t-il après un usage ?
THPDFCSCSignatureProvider traite la Signature Activation Data (SAD) comme à usage unique : elle est effacée de l'état du provider au moment où signatures/signHash est accepté, même quand la signature elle-même arrive plus tard via le polling asynchrone. La SAD est la preuve du service que le signataire a approuvé ces hash précis, et une SAD qui traîne en mémoire est une autorisation qui attend d'ê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 (un OTP, un PIN, ce que le bloc auth du credential exige), et poste credentials/authorize. Un 200 porte la SAD directement ; un 202 porte un handle qui est interrogé via credentials/authorizeCheck jusqu'à MaxPollAttempts (60) fois à PollIntervalMS (250 ms). 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. Si le réseau lâche avant que signHash soit accepté, une SAD obtenue automatiquement est conservée pour que le même lot puisse être relancé sans redemander au signataire
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
// votre client OAuth ; ForceRefresh vaut True après un 401 du service
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 UI
Exit(spsCancelled);
SetLength(Values, 1);
Values[0].ID := 'otp';
Values[0].Value := AnsiString(Otp);
Result := spsValid;
end);
Une SAD que vous passez vous-même via Options.SAD se comporte autrement, et délibérément. HotPDF ne peut pas savoir pour quels hash elle a été émise, donc le provider n'utilise une SAD pré-réglée que pour une requête à hash unique. Pour un lot avec AutoAuthorize désactivé, le provider échoue avec « CSC SAD is not pinned to the requested hash batch » au lieu de deviner
Comment SignHashBatch signe-t-il beaucoup de documents avec une seule autorisation ?
SignHashBatch envoie un credentials/authorize et un signatures/signHash pour jusqu'à MaxBatchSignatures (64) condensés, et bâtit les deux corps depuis le même tableau pour que numSignatures, l'ordre des hashes et hashAlgorithmOID soient identiques dans les deux appels. Cette correspondance est ce que le modèle multisign du CSC exige. Bouclez la méthode Sign à hash unique quarante fois et vous obtenez quarante autorisations ; envoyez un authorize et un signHash en désaccord et le service peut consommer la SAD contre le mauvais lot
Avant tout trafic réseau, le provider valide le lot. 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 même OID d'algorithme de signature, un même OID de condensé et, pour RSASSA-PSS, une même longueur de salt. Un lot multi-hash charge aussi credentials/info et renvoie spsUnsupported quand la valeur multisign du credential est plus petite que le lot. La SAD est alors épinglée à une empreinte de lot — un SHA-256 sur une étiquette de version, le compte et, par requête, l'OID d'algorithme, l'OID de condensé, l'algorithme, la longueur de salt et les octets du condensé, chacun préfixé par sa longueur. Échangez deux hash et c'est un autre lot, qui demande une autorisation fraîche
var
Requests: THPDFCSCSignatureRequests;
Signatures: THPDFCSCSignatures;
Status: THPDFSignatureProviderStatus;
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;
Status := Provider.SignHashBatch(Requests, 'invoices-2026-09-25-a', Signatures);
if Status <> spsValid then
raise Exception.CreateFmt('CSC batch failed (HTTP %d): %s',
[Provider.LastHTTPStatus, Provider.LastError]);
// Signatures[I] correspond à Digests[I] ; le compte a été confronté à la requête
end;
Pour RSASSA-PSS, le provider envoie aussi signAlgoParams, une structure RSASSA-PSS-params DER en base64 avec l'algorithme de hash, MGF1 et la longueur de salt. La construire suppose d'encoder des OIDs, et la version 2.748.5 a corrigé un coin de cela : X.690 §8.19.4 replie les deux premiers arcs en une seule valeur (40 × premier + second), et sous la racine 2 un second arc au-dessus de 39 pousse cette valeur au-delà de 127, où elle réclame la forme multi-octets en base 128 que les builds antérieurs n'appliquaient pas. Aucun OID SHA-2 n'est concerné — 2.16 se replie en 96 — mais un OID mal formé lève désormais l'erreur propre au provider au lieu d'un EConvertError
Pourquoi une requête relancée ne produit-elle pas une seconde signature ?
THPDFCSCSignatureProvider fait porter à chaque appel retentable 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 rend les signatures d'origine au lieu de redemander de nouvelles signatures au HSM. La clé, c'est csc- suivi du SHA-256 hexadécimal de l'identifiant d'opération et de la phase, et la phase embarque l'empreinte du lot tant pour l'autorisation que pour signHash. Hacher plutôt que tronquer compte : deux identifiants d'opération longs qui partagent un préfixe entreraient en collision sous troncature, tandis qu'une clé à longueur fixe adressée par le contenu reste unique et stable entre tentatives
La politique de relance dans le chemin de requête partagé est étroite à dessein :
- Un 401 HTTP force exactement un rafraîchissement de jeton via le callback de jeton d'accès, puis la requête est répétée une fois quand un callback de jeton d'accès est affecté ; un second 401 est définitif
- Les autres réponses 4xx et
ctsPermanentFailureterminent l'appel avecspsProviderError, et leerror_descriptiondu service atterrit dansLastError - Les 408, 429, 5xx et
ctsTemporaryFailuresont relancés jusqu'àRetryLimit(défaut 2), en attendantRetry-AfterouRetryBaseDelayMS× 2attempt (base 100 ms), 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 signHashn'est relancé que tant queEnableIdempotencyest actif ; désactivez-le et un timeout après soumission est définitif, parce que personne ne peut dire si la clé a déjà servi
La signature asynchrone (operationMode « A », le défaut) ajoute un garde de plus : le responseID est stocké avant d'interroger signatures/signPolling, si bien qu'un appel répété avec le même identifiant d'opération reprend le polling au lieu de resoumettre. Les lots terminés reposent dans un cache indexé par identifiant d'opération, credential et empreinte, plafonné par MaxOperationCacheEntries (128) et rendu en copies profondes. Ce cache vit dans l'instance du provider et ne survit pas à un redémarrage. La clé d'idempotence, si, parce qu'elle est dérivée plutôt qu'aléatoire, donc un processus relancé qui réutilise son identifiant d'opération envoie la même clé — savoir si le service déduplique dessus est la promesse du service, pas celle de HotPDF
Comment mettre une signature CSC dans un PDF ?
Passez le provider à HPDFCMSSignPDFStreamWithProvider avec le certificat d'entité finale issu 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 celui couvert par les providers de signatures enfichables HotPDF pour ML-DSA et EdDSA
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;
Le dimensionnement du placeholder /Contents passe par EstimateSignatureSize, qui renvoie EstimatedSignatureBytes quand vous le réglez et sinon la taille du module RSA depuis la longueur de clé du credential. Pour ECDSA, réglez EstimatedSignatureBytes vous-même, sinon l'estimation rapporte spsUnsupported. La variante de signature à taille auto re-signe quand un placeholder s'avère trop petit, et elle ne le fait que pour les providers annonçant spcSafeSignRetry — ce que THPDFCSCSignatureProvider fait seulement tant que EnableIdempotency est actif. Pour les workflows PAdES-B-T, TimestampDigest demande un jeton d'horodatage au même service via signatures/timestamp, plafonné à MaxTimestampBytes (1 Mo)
Que ne fait pas le provider CSC ?
Il ne signe pas des messages, seulement des condensés. Ed25519 et Ed448 en mode pur remettent au provider le message entier des attributs signés (sikMessage), et le validateur de lot le rejette comme mal formé, parce que signHash est par définition fondé sur le hash. L'unité du provider compile sous Free Pascal avec des types de fonctions simples à la place des méthodes anonymes, mais les constructeurs CMS pilotés par le provider lèvent sous FPC aujourd'hui, donc embarquer une signature CSC dans un PDF est un chemin Delphi
Il ne décide pas non plus de la politique. CredentialInfo rapporte le statut de la clé, le statut du certificat, le mode d'autorisation, le niveau SCAL et la limite multisign, mais le provider ne refusera pas de lui-même une clé désactivée ou un credential SCAL1 — vérifiez cela avant de montrer l'invite OTP à un signataire. Et une instance de provider signe un lot à la fois : SignHashBatch est sérialisé en interne pour que deux threads ne se disputent pas une même SAD, ce qui veut dire que le débit vient du batch, pas du partage d'un provider entre threads ouvriers. Que la signature résultante soit qualifiée dépend du service de confiance et de son credential, pas de la bibliothèque qui y a porté le hash
Le provider CSC, les constructeurs CMS et PAdES et les providers local et PKCS#11 sont tous livrés dans le composant PDF HotPDF pour Delphi