Articol tehnic

Semnare CSC remote în HotPDF: semnături PDF cloud din Delphi

HotPDF semnează documente PDF cu o cheie privată deținută de un serviciu remote Cloud Signature Consortium (CSC) prin THPDFCSCSignatureProvider, un signature provider care conduce API-ul CSC — credential info, autorizare, signatures/signHash și polling — în timp ce aplicația voastră Delphi furnizează transportul HTTP și token-ul de acces OAuth. Cheia nu părăsește niciodată HSM-ul serviciului

Asta devine din ce în ce mai mult singura cale de a obține măcar o cheie de semnare calificată. Furnizorii de servicii de încredere dau un endpoint CSC și un client OAuth, nu un fișier PFX sau un token USB, deci nu există nimic de încărcat într-un magazin de certificate local așa cum face semnarea din magazinul de certificate Windows prin CNG și CAPI. Integrația naivă eșuează în moduri previzibile: un apel signHash dă timeout și retry-ul semnează același contract de două ori, sau un lot de patruzeci de facturi declanșează patruzeci de parole de unică folosință pentru că fiecare hash a fost autorizat separat. Cea mai mare parte din ce face provider-ul e apărarea împotriva celor două eșecuri

De ce lasă HotPDF HTTP-ul în seama aplicației voastre?

Pentru că transportul e exact locul în care fiecare implementare diferă. Proxy-urile, TLS pinning, certificatele de client, seifurile corporative OAuth și politica de logging trăiesc toate în stratul HTTP, deci THPDFCSCSignatureProvider orchestrează starea protocolului și apelează o funcție THPDFCSCTransport pentru fiecare cerere. Provider-ul vă dă un THPDFCSCTransportRequest cu Method (mereu POST), URL-ul complet construit din ServiceBaseURL plus calea endpoint-ului, un header Authorization bearer gata făcut, ContentType, Body-ul JSON, un IdempotencyKey, numărul Attempt și MaxResponseBytes. Voi umpleți un THPDFCSCTransportResponse cu StatusCode, Body și RetryAfterMS și întoarceți una dintre ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure sau ctsCancelled

Diagrama frontierei de transport CSC în HotPDF: THPDFCSCSignatureProvider orchestrează protocolul și dă codului vostru un THPDFCSCTransportRequest cu o metodă POST, URL-ul complet, un header Authorization bearer gata făcut, body-ul JSON, un IdempotencyKey și numărul încercării, iar voi întoarceți StatusCode, Body, RetryAfterMS plus una dintre cele patru valori de stare cts, în timp ce cheia nu părăsește niciodată HSM-ul
Provider-ul clasifică singur codurile de stare, deci un transport care transformă un 503 primit cu răspuns într-un eșec permanent dezactivează în tăcere logica de retry, în timp ce proxy-urile și politica TLS rămân în codul pe care îl dețineți
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  // numele header-ului așa cum îl documentează serviciul vostru
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // problemă de socket sau DNS: reîncercabil
        end;
        Response.StatusCode := HttpResp.StatusCode;   // raportați 503 ca atare, nu clasificați
        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;

Regula unică care merită memorată: întoarceți ctsSuccess de fiecare dată când un server chiar a răspuns, chiar și cu un 503. Provider-ul clasifică singur codurile de stare, iar un transport care transformă un 429 în ctsPermanentFailure dezactivează în tăcere logica de retry descrisă mai jos. Constructorul e strict în cealaltă direcție — ridică EHPDFCSCSignatureProviderError când transportul lipsește, CredentialID e gol, nu e furnizat niciun AccessToken și niciun callback de token, un buget e în afara intervalului sau ServiceBaseURL nu e HTTPS. http:// simplu e acceptat doar cu AllowInsecureHTTP, care își are locul într-un banc de test și nicăieri altundeva

Ce este SAD-ul și de ce îl aruncă HotPDF după o singură folosire?

THPDFCSCSignatureProvider tratează Signature Activation Data (SAD) ca single-use: e curățată din starea provider-ului în momentul în care signatures/signHash e acceptat, chiar dacă semnătura în sine sosește mai târziu prin polling asincron. SAD-ul e dovada serviciului că semnatarul a aprobat tocmai acești hash-i, iar o SAD care rămâne în memorie e o autorizare care așteaptă să fie cheltuită pe documentul greșit

Cu implicitele din THPDFCSCOptions.Default — RequireSAD și AutoAuthorize ambele True — provider-ul încarcă credentials/info o dată, cere callback-ului vostru THPDFCSCAuthenticationCallback valorile authData (un OTP, un PIN, orice cere blocul auth al credential-ului) și post-ează credentials/authorize. Un 200 cară SAD-ul direct; un 202 cară un handle care e poll-uit prin credentials/authorizeCheck până la MaxPollAttempts (60) de ori la PollIntervalMS (250 ms). Callback-ul poate întoarce cel mult 32 de valori, fiecare cu un ID non-gol de cel mult 256 de octeți și o valoare de cel mult 4.096 de octeți. Dacă rețeaua pică înainte ca signHash să fie acceptat, o SAD obținută automat e păstrată, ca același lot să poată fi reîncercat fără a mai întreba semnatarul

Diagrama ciclului de viață SAD în HotPDF: cu RequireSAD și AutoAuthorize provider-ul încarcă credentials/info o dată, cere callback-ului de autentificare valori OTP sau PIN, post-ează credentials/authorize, poll-uiește credentials/authorizeCheck până la 60 de ori la 250 ms când răspunsul e 202 și curăță Signature Activation Data în momentul în care signatures/signHash e acceptat, păstrând o SAD obținută când rețeaua a picat înainte de acceptare
O SAD care rămâne în memorie e o autorizare care așteaptă să fie cheltuită pe documentul greșit, iar o SAD presetată pasată prin opțiuni e folosită doar pentru o cerere cu un singur hash
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, mod async, 2 retry-uri
  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
      // clientul vostru OAuth; ForceRefresh e True după ce serviciul a răspuns 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   // UI-ul vostru
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

O SAD pe care o pasați voi prin Options.SAD se comportă diferit, și asta în mod deliberat. HotPDF nu poate ști pentru ce hash-i a fost emisă, deci provider-ul folosește o SAD presetată doar pentru o cerere cu un singur hash. Pentru un lot cu AutoAuthorize oprit, provider-ul eșuează cu „CSC SAD is not pinned to the requested hash batch” în loc să ghicească

Cum semnează SignHashBatch multe documente cu o singură autorizare?

SignHashBatch trimite un singur credentials/authorize și un singur signatures/signHash pentru până la MaxBatchSignatures (64) de digest-uri și construiește ambele body-uri din același tablou, astfel încât numSignatures, ordinea hashes și hashAlgorithmOID să fie identice în cele două apeluri. Potrivirea aceea e exact ce cere modelul multisign CSC. Buclați metoda single-hash Sign de patruzeci de ori și primiți patruzeci de autorizări; trimiteți un authorize și un signHash care nu sunt de acord și serviciul poate consuma SAD-ul pe lotul greșit

Înainte de orice trafic de rețea, provider-ul validează lotul. Fiecare cerere trebuie să fie un digest (sikDigest) de 1 până la 1.024 de octeți cu un OID de digest, iar toate cererile trebuie să partajeze un OID de algoritm de semnătură, un OID de digest și, pentru RSASSA-PSS, o lungime de salt. Un lot multi-hash mai încarcă și credentials/info și întoarce spsUnsupported când valoarea multisign a credential-ului e mai mică decât lotul. SAD-ul e apoi fixat pe o amprentă a lotului — un SHA-256 peste o etichetă de versiune, numărătoarea și, per cerere, OID-ul algoritmului, OID-ul digest-ului, algoritmul, lungimea salt-ului și octeții digest-ului, fiecare prefixat cu lungimea. Schimbați doi hash-i între ei și e un alt lot, care are nevoie de o autorizare proaspătă

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: valorile SHA-256 pe care le-ați calculat
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo derivat când AlgorithmOID e gol
    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] aparține lui Digests[I]; numărul a fost verificat față de cerere
end;

Pentru RSASSA-PSS, provider-ul mai trimite și signAlgoParams, o structură RSASSA-PSS-params DER în base64 cu algoritmul de hash, MGF1 și lungimea salt-ului. Construirea ei înseamnă encodare de OID-uri, iar versiunea 2.748.5 a reparat un colț din asta: X.690 §8.19.4 pliază primele două arce într-o singură valoare (40 × primul + al doilea), iar sub rădăcina 2 un al doilea arc peste 39 împinge valoarea aceea peste 127, unde are nevoie de forma multi-byte base-128 pe care build-urile anterioare nu o aplicau. Niciun OID SHA-2 nu e afectat — 2.16 se pliază la 96 — dar un OID malformat ridică acum eroarea proprie a provider-ului în loc de un EConvertError

De ce o cerere reîncercată nu produce o a doua semnătură?

THPDFCSCSignatureProvider face ca fiecare apel reîncercabil să cară o cheie de idempotență deterministă și ține cache rezultatele completate, deci un retry după un răspuns pierdut întoarce semnăturile originale în loc să ceară HSM-ului unele noi. Cheia e csc- urmat de SHA-256 hexazecimal al identificatorului de operație și al fazei, iar faza încorporează amprenta lotului atât pentru autorizare, cât și pentru signHash. Hash-ing în loc de trunchiere contează: două ID-uri de operație lungi care partajează un prefix ar colisiona la trunchiere, în timp ce o cheie de lungime fixă, adresată după conținut, rămâne unică și stabilă între încercări

Politica de retry din calea de cereri partajată e îngustă din principiu:

  • Un HTTP 401 forțează exact un refresh de token prin callback-ul de access-token, apoi cererea e repetată o dată când un callback de access-token e asignat; un al doilea 401 e final
  • Celelalte răspunsuri 4xx și ctsPermanentFailure termină apelul cu spsProviderError, iar error_description-ul serviciului aterizează în LastError
  • 408, 429, 5xx și ctsTemporaryFailure sunt reîncercate până la RetryLimit (implicit 2), așteptând Retry-After sau RetryBaseDelayMS × 2attempt (bază 100 ms), plafonat la MaxRetryAfterMS (5.000 ms)
  • Așteptările rulează în felii de 25 ms care verifică Cancel, deci un utilizator care abandonează nu stă printr-un back-off de cinci secunde
  • signHash e reîncercat doar cât timp EnableIdempotency e pornit; opriți-l și un timeout după trimitere e final, pentru că nimeni nu poate spune dacă cheia fusese deja folosită
Diagrama politicii de retry în HotPDF: fiecare apel reîncercabil cară o cheie de idempotență csc- deterministă, hash-uită din identificatorul de operație și fază, un HTTP 401 forțează exact un refresh de token, celelalte răspunsuri 4xx se termină cu spsProviderError, iar 408, 429, 5xx sau un eșec temporar de transport sunt reîncercate până la RetryLimit 2, așteptând Retry-After sau un backoff exponențial plafonat la 5.000 ms
Loturile completate țin cache după identificator de operație, credential și amprentă, iar în modul asincron responseID-ul stocat permite unui apel repetat să reia polling-ul în loc să retrimită hash-ul

Semnarea asincronă (operationMode „A”, implicitul) adaugă o gardă în plus: responseID-ul e stocat înainte de polling-ul lui signatures/signPolling, deci un apel repetat cu același identificator de operație reia polling-ul în loc să re-trimite. Loturile completate stau într-un cache cheiat după identificator de operație, credential și amprentă, plafonat de MaxOperationCacheEntries (128) și întors ca copii profunde. Cache-ul acela trăiește în instanța provider-ului și nu supraviețuiește unui restart. Cheia de idempotență da, pentru că e derivată, nu aleatoare, deci un proces repornit care își refolosește identificatorul de operație trimite aceeași cheie — dacă serviciul deduplichează pe ea e promisiunea serviciului, nu a HotPDF

Cum puneți o semnătură CSC într-un PDF?

Pasați provider-ul către HPDFCMSSignPDFStreamWithProvider împreună cu certificatul end-entity de la GetCertificateChain; HotPDF construiește CMS SignedData, iar provider-ul semnează digest-ul atributelor semnate. PDF-ul de intrare are nevoie de placeholder-ele /ByteRange și /Contents pe care THPDFPage.AddSignedSignatureField le scrie, exact ca în fluxul de semnare PAdES din HotPDF, iar modelul de provider e același acoperit în provider-ele de semnătură plugabile HotPDF pentru ML-DSA și EdDSA

var
  Chain: THPDFCSCCertificateChain;
  SignOpts: THPDFCMSSignOptions;
  Src, Dst: TFileStream;
begin
  if Provider.RefreshCredentialInfo <> spsValid then
    raise Exception.Create(Provider.LastError);
  Chain := Provider.GetCertificateChain;   // CSC listează certificatul end-entity primul
  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;

Dimensionarea placeholder-ului /Contents trece prin EstimateSignatureSize, care întoarce EstimatedSignatureBytes când îl setați și, în caz contrar, dimensiunea modulului RSA din lungimea cheii credential-ului. Pentru ECDSA setați voi EstimatedSignatureBytes, sau estimarea raportează spsUnsupported. Varianta de semnare cu dimensionare automată re-semnează când un placeholder iese prea mic, și face asta doar pentru provider-e care anunță spcSafeSignRetry — lucru pe care THPDFCSCSignatureProvider îl face doar cât timp EnableIdempotency e pornit. Pentru fluxuri PAdES-B-T, TimestampDigest cere un timestamp token de la același serviciu prin signatures/timestamp, plafonat la MaxTimestampBytes (1 MB)

Ce nu face provider-ul CSC?

Nu semnează mesaje, ci doar digest-uri. Ed25519 și Ed448 în modul pur dau provider-ului tot mesajul de atribute semnate (sikMessage), iar validatorul de lot respinge asta ca malformat, pentru că signHash e prin definiție bazat pe hash. Unitatea provider-ului se compilează sub Free Pascal cu tipuri de funcții simple în locul metodelor anonime, dar builder-ele CMS conduse de provider ridică excepție sub FPC în prezent, deci încorporarea unei semnături CSC într-un PDF e o cale Delphi

Nu decide nici politica. CredentialInfo raportează starea cheii, starea certificatului, modul de autorizare, nivelul SCAL și limita multisign, dar provider-ul nu va refuza singur o cheie dezactivată sau un credential SCAL1 — verificați-le înainte să arătați semnatarului promptul de OTP. Și o instanță de provider semnează un singur lot odată: SignHashBatch e serializat intern, deci două thread-uri nu pot intra în cursă pentru o SAD, ceea ce înseamnă că throughput-ul vine din gruparea în loturi, nu din partajarea unui provider între worker threads. Dacă semnătura rezultată e calificată depinde de serviciul de încredere și de credential-ul lui, nu de biblioteca care a dus hash-ul acolo

Provider-ul CSC, builder-ele CMS și PAdES și provider-ele local și PKCS#11 se livrează în HotPDF Delphi PDF component