Una richiesta signatures/signHash a un servizio di firma remota può andare in timeout dopo che l'HSM ha già prodotto la firma, e il client non può dire su quale lato di quella riga sia atterrato. Ritentare alla cieca e una sola approvazione può produrre due firme sullo stesso contratto. Rifiutarsi di ritentare e un lotto notturno muore su un singolo 503. Firmare quaranta fatture un hash alla volta e il firmatario digita quaranta one-time password
Qualsiasi integrazione del Cloud Signature Consortium (CSC) deve risolvere entrambe le cose. I trust service provider che emettono chiavi qualificate consegnano sempre più spesso un endpoint API CSC e un client OAuth anziché un file PFX o un token USB, quindi non c'è nulla da importare in uno store locale nel modo in cui lavora la firma con il cert store di Windows via CNG e CAPI. La chiave resta nell'HSM del provider, e ogni firma è un giro di andata e ritorno su HTTPS
Perché HotPDF lascia HTTP alla tua applicazione?
HotPDF firma attraverso un servizio CSC remoto con THPDFCSCSignatureProvider, un signature provider che esegue il protocollo CSC v2 (credential info, autorizzazione, signatures/signHash, polling) mentre il codice Delphi tuo possiede il trasporto HTTP e il token OAuth. Proxy, TLS pinning, certificati client, vault di token e logging delle richieste vivono tutti nel layer HTTP, e differiscono in ogni deployment
Per ogni richiesta il provider costruisce una THPDFCSCTransportRequest e chiama la tua funzione THPDFCSCTransport. Il record porta il URL completo, un header Authorization bearer pronto, il Body JSON, un IdempotencyKey, l'OperationIdentifier e il numero di Attempt. La tua funzione riempie una THPDFCSCTransportResponse con StatusCode, Body e RetryAfterMS, poi restituisce ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure o ctsCancelled
Un trasporto costruito sul THTTPClient della RTL richiede circa quaranta righe. Il body esce come byte grezzi, così il JSON non passa mai attraverso una conversione di code page:
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 // nome dell'header come lo documenta il tuo servizio
Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
string(Request.IdempotencyKey))];
try
HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
except
on ENetHTTPClientException do
Exit(ctsTemporaryFailure); // DNS, TLS o guai di socket: ritentabile
end;
Response.StatusCode := HttpResp.StatusCode; // fai passare 429 e 503 intatti
SetLength(Response.Body, Reply.Size);
if Reply.Size > 0 then
Move(Reply.Memory^, Response.Body[1], Reply.Size);
// Retry-After in secondi; un valore HTTP-date ripiega a 0 qui
Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
Result := ctsSuccess;
finally
Reply.Free;
Body.Free;
end;
end;
end;
L'unica regola da memorizzare: restituisci ctsSuccess ogni volta che un server ha risposto, anche con un 503. Il provider classifica da solo i codici di stato, e un trasporto che mappa un 429 a ctsPermanentFailure spegne la logica di retry descritta qui sotto. I body più grandi di MaxResponseBytes (2 MB per default) vengono rifiutati con spsMalformed qualunque cosa restituisca il trasporto
Il costruttore è severo nell'altra direzione. Solleva EHPDFCSCSignatureProviderError quando manca il trasporto, ServiceBaseURL o CredentialID è vuoto, non è fornito né un AccessToken né una callback di token, un budget è fuori range, o l'URL non è HTTPS. Il semplice http:// passa solo con AllowInsecureHTTP impostato, e quel flag sta in un rig di test puntato a un servizio mock locale
Che cos'è il SAD, e perché è monouso?
THPDFCSCSignatureProvider tratta la Signature Activation Data (SAD) come monouso: viene cancellata dallo stato del provider nell'istante in cui signatures/signHash è accettata, anche quando la firma in sé arriva dopo attraverso il polling asincrono. Il SAD è la prova del servizio che il firmatario ha approvato proprio questi hash. Uno che resta in memoria è un'autorizzazione in attesa di essere spesa sul documento sbagliato
Con i default di THPDFCSCOptions.Default (RequireSAD e AutoAuthorize entrambi True), il provider carica credentials/info una volta, chiede alla tua THPDFCSCAuthenticationCallback i valori authData che il blocco auth della credenziale esige, di solito un OTP o un PIN, e invia credentials/authorize. Una risposta 200 porta il SAD direttamente. Una 202 porta un handle, che il provider polla attraverso credentials/authorizeCheck fino a MaxPollAttempts (60) volte a PollIntervalMS (250 ms) a meno che il servizio non invii Retry-After. La callback può restituire al massimo 32 valori, ciascuno con un ID non vuoto fino a 256 byte e un valore fino a 4.096 byte
La cancellazione avviene dopo l'accettazione. Se la rete cade prima che signHash sia accettato, un SAD ottenuto automaticamente resta al suo posto, ancora legato allo stesso lotto, così il retry parte senza una seconda richiesta di OTP. Il firmatario ha approvato una volta; il provider spende quell'approvazione una volta
var
Options: THPDFCSCOptions;
Provider: THPDFCSCSignatureProvider;
begin
Options := THPDFCSCOptions.Default; // RequireSAD, AutoAuthorize, modalità async, 2 retry
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
// il tuo client OAuth; ForceRefresh è True dopo che il servizio ha risposto 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 // la tua UI
Exit(spsCancelled);
SetLength(Values, 1);
Values[0].ID := 'otp';
Values[0].Value := AnsiString(Otp);
Result := spsValid;
end);
La callback del token scatta quando il token in cache manca o è entro TokenRefreshSkewSeconds (30) dalla scadenza che hai riportato; un ExpiresAtUTC di zero significa che il token viene riusato finché un 401 non forza un refresh
Un SAD che passi tu stesso attraverso Options.SAD si comporta diversamente, e di proposito. Nulla dice al provider quali hash copre quel SAD, quindi un SAD preimpostato viene usato solo per una richiesta a hash singolo. Per un batch multi-hash con AutoAuthorize acceso scarta il valore preimpostato e autorizza il batch; con AutoAuthorize spento fallisce con "CSC SAD is not pinned to the requested hash batch" anziché tirare a indovinare. I servizi che autorizzano solo tramite lo scope OAuth e non emettono mai un SAD lavorano con RequireSAD impostato a False, nel qual caso il membro SAD resta fuori dal body di signHash
Firmare quaranta fatture con una sola richiesta di OTP
SignHashBatch invia un solo credentials/authorize e un solo signatures/signHash per fino a MaxBatchSignatures (64) digest, e costruisce entrambi i body dallo stesso array, così numSignatures, l'ordine degli hashes e hashAlgorithmOID concordano tra le due chiamate. Fai il loop quaranta volte sul metodo Sign a hash singolo e ottieni quaranta autorizzazioni. Invia un authorize e un signHash in disaccordo e il servizio può consumare il SAD contro il batch sbagliato
La validazione gira prima di qualunque traffico di rete. Ogni richiesta deve essere un digest (sikDigest) da 1 a 1.024 byte con un OID di digest, e tutte le richieste devono condividere un solo algoritmo di firma, un OID di digest e, per RSASSA-PSS, una lunghezza di salt. Un batch multi-hash carica anche credentials/info e restituisce spsUnsupported quando il valore multisign della credenziale è più piccolo della dimensione del batch. L'autorizzazione viene poi legata a un fingerprint SHA-256 sensibile all'ordine della lista di richieste, così scambiare due hash fa un batch diverso che richiede un'approvazione fresca
var
Requests: THPDFCSCSignatureRequests;
Signatures: THPDFCSCSignatures;
Status: THPDFSignatureProviderStatus;
OpId: AnsiString;
I: Integer;
begin
SetLength(Requests, Length(Digests)); // Digests: valori SHA-256 che hai calcolato
for I := 0 to High(Digests) do
begin
Requests[I] := Default(THPDFSignatureProviderRequest);
Requests[I].Algorithm := hsaRSAPKCS1v15; // signAlgo derivato quando AlgorithmOID è vuoto
Requests[I].DigestAlgorithmOID := '2.16.840.1.101.3.4.2.1';
Requests[I].InputKind := sikDigest;
Requests[I].Input := Digests[I];
end;
// Ricava l'ID dal tuo record di esecuzione, mai da un GUID fresco per chiamata
OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
Status := Provider.SignHashBatch(Requests, OpId, Signatures);
if Status = spsProviderError then
// Stesso OpId: un batch completato torna dalla cache, un job async
// in sospeso riprende il polling, una richiesta non inviata riusa la stessa chiave
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] appartiene a Digests[I]; il conteggio è stato verificato contro la richiesta
end;
Per RSASSA-PSS il provider aggiunge signAlgoParams, una struttura RSASSA-PSS-params DER in base64 che porta l'algoritmo di hash, MGF1 e la lunghezza del salt. X.690 §8.19.4 ripiega i primi due archi OID in un unico valore (40 × primo + secondo), e sotto la radice 2 un secondo arco sopra 39 spinge quel valore oltre 127, dove serve la forma multi-byte base-128. Nessun OID SHA-2 percorre quella via, dato che 2.16 ripiega a 96, così un encoder può passare ogni test SHA-2 ed essere comunque sbagliato. Il provider scrive la forma multi-byte e riporta un OID malformato come proprio EHPDFCSCSignatureProviderError anziché come EConvertError
Perché una richiesta ritentata non produce una seconda firma?
THPDFCSCSignatureProvider fa sì che ogni chiamata ritentabile porti una idempotency key deterministica e metta in cache i risultati completati, così un retry dopo una risposta perduta restituisce le firme originali invece di chiedere all'HSM firme nuove. La chiave è csc- seguita dallo SHA-256 esadecimale dell'identificatore di operazione e della fase del protocollo, e per l'autorizzazione e signHash la fase include il fingerprint del batch. Fare l'hash invece di troncare mantiene la chiave a lunghezza fissa e impedisce che due identificatori di operazione lunghi con un prefisso condiviso collidano
La policy di retry nel percorso di richiesta condiviso è stretta:
- Un HTTP 401 forza esattamente un refresh del token attraverso la callback dell'access token, e la richiesta viene ripetuta una volta; un secondo 401 è definitivo, e senza una callback di token lo è già il primo
- Le altre risposte 4xx e
ctsPermanentFailurechiudono la chiamata conspsProviderError, e l'error_descriptiondel servizio (oerror) finisce inLastError - 408, 429, 5xx e
ctsTemporaryFailurevengono ritentati fino aRetryLimit(default 2), aspettandoRetry-AfteroRetryBaseDelayMS(100 ms) raddoppiato a ogni tentativo, con tetto aMaxRetryAfterMS(5.000 ms) - Le attese girano a fette da 25 ms che controllano
Cancel, così un utente che interrompe non resta seduto cinque secondi di back-off signHashviene ritentato solo conEnableIdempotencyacceso; spegnilo e un timeout dopo l'invio è definitivo, perché nessuno può dire se l'hash sia già stato firmato
La firma asincrona (operationMode "A", il default, con un validity_period di AsyncValidityPeriodMS, 60.000 ms) aggiunge un'ulteriore guardia. Il responseID viene memorizzato prima che il provider cominci a pollare signatures/signPolling, così una chiamata ripetuta con lo stesso identificatore di operazione riprende il job in sospeso invece di risottomettere l'hash. I batch completati vengono messi in cache per istanza di provider, fino a MaxOperationCacheEntries (128), e quella cache sparisce dopo un riavvio. La idempotency key sopravvive, perché è derivata anziché casuale: un processo riavviato che riusa il proprio identificatore di operazione invia la stessa chiave, e se il servizio deduplichi su di essa è parte del contratto del servizio
Tutto questo si regge sull'identificatore di operazione, e il parametro è facile da lasciare vuoto. Passa '' a SignHashBatch e il provider ripiega sull'OperationIdentifier delle richieste, poi su un nuovo GUID. Un nuovo GUID per chiamata significa che il tuo stesso loop di retry avvia una nuova operazione ogni volta: nuovo slot in cache, nuova idempotency key, e possibilmente una seconda richiesta di OTP. Significa anche che Provider.Cancel non ha nulla da bersagliare, dato che il tuo codice non ha mai visto il GUID. Ricava l'identificatore da qualcosa che già persisti, come il numero di batch o l'ID del documento, e tieni EnableIdempotency al suo default True. Se un servizio rifiuta l'header, buttalo nel trasporto invece di spegnere l'opzione
Incorporare una firma CSC in un PDF
Passa il provider a HPDFCMSSignPDFStreamWithProvider insieme al certificato end-entity da GetCertificateChain; HotPDF costruisce il CMS SignedData e il provider firma il digest degli signed attributes. Il PDF di input ha bisogno del segnaposto /ByteRange e /Contents che THPDFPage.AddSignedSignatureField scrive, esattamente come nel flusso di firma PAdES in HotPDF, e il modello del provider è lo stesso alla base di provider di firma pluggabili HotPDF per ML-DSA e EdDSA
var
Chain: THPDFCSCCertificateChain;
SignOpts: THPDFCMSSignOptions;
Src, Dst: TFileStream;
begin
if Provider.RefreshCredentialInfo <> spsValid then
raise Exception.Create(Provider.LastError);
Chain := Provider.GetCertificateChain; // CSC elenca il certificato end-entity per primo
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;
Per la dimensione del segnaposto, EstimateSignatureSize restituisce EstimatedSignatureBytes quando lo imposti, altrimenti la dimensione del modulo RSA ricavata dalla lunghezza della chiave in credentials/info. Per chiavi ECDSA risponde spsUnsupported e lo stimatore del segnaposto ripiega sul certificato, quindi impostare tu EstimatedSignatureBytes dà un risultato prevedibile. HPDFCMSSignPDFStreamWithProviderAutoSize ricostruisce e rifirma quando un segnaposto risulta troppo piccolo, e lo fa solo per provider che dichiarano spcSafeSignRetry. THPDFCSCSignatureProvider lo dichiara con EnableIdempotency acceso, che è un motivo in più per lasciare quel default dov'è
Per il PAdES-B-T, TimestampDigest chiede allo stesso servizio un token RFC 3161 attraverso signatures/timestamp, con un nonce opzionale e un tetto di dimensione MaxTimestampBytes (1 MB)
Decisioni di policy che restano nel tuo codice
Il provider firma digest. Ed25519 e Ed448 in modalità pura consegnano a un signature provider l'intero messaggio degli signed attributes (sikMessage), e il validatore di batch lo rifiuta come malformato, perché signHash è per definizione basato sull'hash. Sotto Free Pascal la unit del provider compila con tipi funzione semplici al posto dei metodi anonimi, quindi SignHashBatch e TimestampDigest sono chiamabili lì; incorporare il risultato in un PDF attraverso HPDFCMSSignPDFStreamWithProvider è un percorso Delphi
Il provider lascia la policy anche a te. CredentialInfo riporta KeyStatus, CertificateStatus, AuthorizationMode, SCAL e MultiSignatures, ma rifiutare una chiave disabilitata o una credenziale SCAL1 è una chiamata tua. Controlla quei campi subito dopo RefreshCredentialInfo e prima che il firmatario veda mai una richiesta di OTP; un rifiuto del servizio dopo che il firmatario ha digitato un codice è un'esperienza peggiore di un errore chiaro all'inizio
Una istanza di provider esegue un batch alla volta, perché SignHashBatch, RefreshCredentialInfo e TimestampDigest condividono un lock così due thread non possono contendersi lo stesso SAD. Il throughput viene da batch più grandi, fino al limite multisign della credenziale. Se la firma risultante conta come qualificata lo decidono il trust service e la credenziale che ha emesso
Il provider CSC viene insieme ai builder CMS e PAdES e ai provider di firma locali e PKCS#11 nell'HotPDF Delphi PDF component, così firma remota, a token e da file alimentano tutte le stesse chiamate di firma PDF