Articolo tecnico

Firma remota CSC con HotPDF: firme PDF cloud in Delphi

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

Diagramma del confine di trasporto CSC in HotPDF: THPDFCSCSignatureProvider orchestra il protocollo e consegna al tuo codice una THPDFCSCTransportRequest con metodo POST, URL completo, header Authorization bearer pronto, body JSON, IdempotencyKey e numero di tentativo, e tu restituisci StatusCode, Body, RetryAfterMS più uno dei quattro valori di stato cts mentre la chiave non lascia mai l'HSM
Il provider classifica da solo i codici di stato, quindi un trasporto che trasforma un 503 risposto in un fallimento permanente disattiva in silenzio la logica di retry, mentre proxy e policy TLS restano in codice tuo

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

Diagramma del ciclo di vita del SAD in HotPDF: con RequireSAD e AutoAuthorize il provider carica credentials/info una volta, chiede alla callback di autenticazione i valori OTP o PIN, invia credentials/authorize, polla credentials/authorizeCheck fino a 60 volte a 250 ms quando la risposta è 202, e cancella la Signature Activation Data nell'istante in cui signatures/signHash è accettata, conservando un SAD ottenuto mentre la rete è caduta prima dell'accettazione
Un SAD che resta in memoria è un'autorizzazione in attesa di essere spesa sul documento sbagliato, e un SAD preimpostato passato attraverso le opzioni è usato solo per una richiesta a hash singolo
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 ctsPermanentFailure chiudono la chiamata con spsProviderError, e l'error_description del servizio (o error) finisce in LastError
  • 408, 429, 5xx e ctsTemporaryFailure vengono ritentati fino a RetryLimit (default 2), aspettando Retry-After o RetryBaseDelayMS (100 ms) raddoppiato a ogni tentativo, con tetto a MaxRetryAfterMS (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
  • signHash viene ritentato solo con EnableIdempotency acceso; spegnilo e un timeout dopo l'invio è definitivo, perché nessuno può dire se l'hash sia già stato firmato
Diagramma della policy di retry in HotPDF: ogni chiamata ritentabile porta una idempotency key deterministica csc- fatta con hash dall'identificatore di operazione e dalla fase, un HTTP 401 forza esattamente un refresh del token, le altre risposte 4xx chiudono con spsProviderError, e 408, 429, 5xx o un fallimento temporaneo del trasporto vengono ritentati fino a un RetryLimit di 2 aspettando Retry-After o un backoff esponenziale con tetto a 5.000 ms
I batch completati vengono messi in cache per identificatore di operazione, credenziale e fingerprint, e in modalità asincrona il responseID memorizzato fa sì che una chiamata ripetuta riprenda il polling invece di risottomettere l'hash

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