Artykuł techniczny

HotPDF CSC: zdalne podpisywanie PDF z Delphi

Żądanie signatures/signHash do zdalnej usługi podpisywania może wypaść na timeout, gdy HSM już wyprodukował podpis, i klient nie umie powiedzieć, po której stronie tej granicy wylądował. Ponawiaj ślepo i jedno zatwierdzenie może dać dwa podpisy na tej samej umowie. Odmów ponowienia i nocna partia padnie na jednej 503. Podpisuj czterdzieści faktur po jednym hashu naraz, a podpisujący wpisze czterdzieści jednorazowych haseł

Każda integracja Cloud Signature Consortium (CSC) musi rozwiązać oba. Dostawcy usług zaufanych wydający kwalifikowane klucze coraz częściej wręczają punkt końcowy CSC API i klienta OAuth zamiast pliku PFX albo tokena USB, więc nie ma czego importować do lokalnego sklepu tak, jak działa podpisywanie ze sklepu certyfikatów Windows przez CNG i CAPI. Klucz zostaje w HSM dostawcy, a każdy podpis to podróż tam i z powrotem po HTTPS

Dlaczego HotPDF zostawia HTTP twojej aplikacji?

HotPDF podpisuje przez zdalną usługę CSC przez THPDFCSCSignatureProvider — providera podpisów, który prowadzi protokół CSC v2 (credential info, autoryzacja, signatures/signHash, pollowanie), podczas gdy Twój kod Delphi posiada transport HTTP i token OAuth. Proxy, przypinanie TLS, certyfikaty klienta, sejfy tokenów i logowanie żądań mieszkają wszystkie w warstwie HTTP i różnią się w każdym wdrożeniu

Dla każdego żądania provider buduje THPDFCSCTransportRequest i woła Twoją funkcję THPDFCSCTransport. Rekord niesie pełny URL, gotowy nagłówek bearer Authorization, Body JSON, IdempotencyKey, OperationIdentifier i numer Attempt. Twoja funkcja wypełnia THPDFCSCTransportResponse polami StatusCode, Body i RetryAfterMS, po czym zwraca ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure albo ctsCancelled

Diagram granicy transportu CSC w HotPDF: THPDFCSCSignatureProvider orkiestruje protokół i wręcza twojemu kodowi THPDFCSCTransportRequest z metodą POST, pełnym URL, gotowym nagłówkiem Authorization bearer, ciałem JSON, IdempotencyKey i numerem próby, a ty zwracasz StatusCode, Body, RetryAfterMS plus jedno z czterech wartości statusu cts, podczas gdy klucz nigdy nie opuszcza HSM
Provider sam klasyfikuje kody statusów, więc transport, który zamienia odpowiedzianą 503 na porażkę permanentną, po cichu wyłącza logikę ponowień, podczas gdy proxy i polityka TLS zostają w kodzie, który jest twój

Transport zbudowany na RTL-owym THTTPClient potrzebuje około czterdziestu linii. Ciało wychodzi jako surowe bajty, więc JSON nigdy nie przechodzi przez konwersję strony kodowej:

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   // nazwa nagłówka tak, jak dokumentuje ją Twoja usługa
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // kłopoty DNS, TLS albo socketa: do ponowienia
        end;
        Response.StatusCode := HttpResp.StatusCode; // przepuść 429 i 503 nietknięte
        SetLength(Response.Body, Reply.Size);
        if Reply.Size > 0 then
          Move(Reply.Memory^, Response.Body[1], Reply.Size);
        // Retry-After w sekundach; wartość HTTP-date spada tu do 0
        Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
        Result := ctsSuccess;
      finally
        Reply.Free;
        Body.Free;
      end;
    end;
end;

Jedna reguła do zapamiętania: zwracaj ctsSuccess zawsze, gdy serwer odpowiedział, nawet 503. Provider sam klasyfikuje kody statusów, a transport mapujący 429 na ctsPermanentFailure wyłącza logikę ponowień opisaną niżej. Ciała większe niż MaxResponseBytes (domyślnie 2 MB) są odrzucane z spsMalformed, cokolwiek transport zwróci

Konstruktor jest surowy w drugą stronę. Podnosi EHPDFCSCSignatureProviderError, gdy brakuje transportu, ServiceBaseURL albo CredentialID jest puste, nie podano ani AccessToken, ani callbacka tokena, budżet jest poza zakresem albo URL nie jest HTTPS. Gołe http:// przechodzi tylko z ustawionym AllowInsecureHTTP, a ta flaga należy do stanowiska testowego celującego w lokalną atrapę usługi

Co to jest SAD i czemu jest jednorazowy?

THPDFCSCSignatureProvider traktuje Signature Activation Data (SAD) jako jednorazową: jest czyszczona ze stanu providera w chwili przyjęcia signatures/signHash, nawet gdy sam podpis przychodzi później przez asynchroniczne pollowanie. SAD to dowód usługi, że podpisujący zatwierdził właśnie te hashe. SAD zalegająca w pamięci to autoryzacja czekająca na wydanie na złym dokumencie

Z domyślnymi z THPDFCSCOptions.Default (RequireSAD i AutoAuthorize oba True) provider wczytuje raz credentials/info, pyta Twój THPDFCSCAuthenticationCallback o wartości authData, których żada blok auth poświadczenia — zwykle OTP albo PIN — i wysyła credentials/authorize. Odpowiedź 200 niesie SAD wprost. 202 niesie uchwyt, który provider polluje przez credentials/authorizeCheck do MaxPollAttempts (60) razy co PollIntervalMS (250 ms), chyba że usługa wyśle Retry-After. Callback może zwrócić najwyżej 32 wartości, każdą z niepustym ID do 256 bajtów i wartością do 4096 bajtów

Czyszczenie dzieje się po przyjęciu. Jeśli sieć padnie, zanim signHash zostanie przyjęty, automatycznie zdobyta SAD zostaje na miejscu, nadal związana z tą samą partią, więc ponowienie wychodzi bez drugiego pytania o OTP. Podpisujący zatwierdził raz; provider wydaje to zatwierdzenie raz

Diagram cyklu życia SAD w HotPDF: przy RequireSAD i AutoAuthorize provider wczytuje raz credentials/info, pyta callback uwierzytelniania o wartości OTP albo PIN, wysyła credentials/authorize, polluje credentials/authorizeCheck do 60 razy co 250 ms, gdy odpowiedź to 202, i czyści Signature Activation Data w chwili przyjęcia signatures/signHash, zachowując zdobyte SAD, gdy sieć padła przed przyjęciem
SAD zalegający w pamięci to autoryzacja czekająca na wydanie na niewłaściwy dokument, a wstępnie ustawione SAD przekazane przez opcje jest używane wyłącznie dla żądania pojedynczego hasha
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, tryb async, 2 ponowienia
  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 to Twój własny klient OAuth; ForceRefresh jest True po 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   // Twoje własne UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

Callback tokena odpala, gdy cached token brakuje albo jest w promieniu TokenRefreshSkewSeconds (30) od zadeklarowanego przez Ciebie wygaśnięcia; ExpiresAtUTC równe zero znaczy, że token jest reużywany, aż 401 wymusi odświeżenie

SAD, którą podajesz sam przez Options.SAD, zachowuje się inaczej — i celowo. Nic nie mówi providerowi, które hashe ta SAD pokrywa, więc wstępnie ustawiona SAD jest używana wyłącznie dla żądania pojedynczego hasha. Dla wielohashowej partii z włączonym AutoAuthorize zrzuca wartość wstępną i autoryzuje partię; z wyłączonym AutoAuthorize pada z „CSC SAD is not pinned to the requested hash batch” zamiast zgadywać. Usługi autoryzujące samym zakresem OAuth i nigdy nie wystawiające SAD działają z RequireSAD ustawionym na False — wówczas człon SAD jest pomijany w ciele signHash

Podpisywanie czterdziestu faktur jednym pytaniem o OTP

SignHashBatch wysyła jedno credentials/authorize i jedno signatures/signHash dla maksymalnie MaxBatchSignatures (64) skrótów i buduje oba ciała z tej samej tablicy, więc numSignatures, kolejność hashes i hashAlgorithmOID zgadzają się między dwoma wywołaniami. Zapętl metodę pojedynczego hasha Sign czterdzieści razy, a dostaniesz czterdzieści autoryzacji. Wyślij authorize i signHash, które się różnią, a usługa może wydać SAD przeciw złej partii

Walidacja biegnie przed jakimkolwiek ruchem sieciowym. Każde żądanie musi być skrótem (sikDigest) od 1 do 1024 bajtów z OID-em skrótu, a wszystkie żądania muszą dzielić jeden algorytm podpisu, jeden OID skrótu i, dla RSASSA-PSS, jedną długość soli. Wielohashowa partia wczytuje też credentials/info i zwraca spsUnsupported, gdy wartość multisign poświadczenia jest mniejsza od rozmiaru partii. Autoryzacja jest potem związana z wrażliwym na kolejność odciskiem SHA-256 listy żądań, więc zamiana dwóch hashów daje inną partię, która potrzebuje świeżego zatwierdzenia

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  OpId: AnsiString;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: wartości SHA-256, które policzyłeś
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;   // signAlgo wyprowadzane, gdy AlgorithmOID jest puste
    Requests[I].DigestAlgorithmOID := '2.16.840.1.101.3.4.2.1';
    Requests[I].InputKind := sikDigest;
    Requests[I].Input := Digests[I];
  end;

  // Wyprowadź ID z własnego rekordu biegu, nigdy ze świeżego GUID-a na wywołanie
  OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
  Status := Provider.SignHashBatch(Requests, OpId, Signatures);
  if Status = spsProviderError then
    // Ten sam OpId: skończona partia wraca z cache, oczekująca
    // praca async wraca do pollowania, niewysłane żądanie reużywa ten sam klucz
    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] należy do Digests[I]; licznik był sprawdzony względem żądania
end;

Dla RSASSA-PSS provider dokleja signAlgoParams, strukturę base64 DER RSASSA-PSS-params niosącą algorytm hasza, MGF1 i długość soli. X.690 §8.19.4 składa pierwsze dwa łuki OID-a w jedną wartość (40 × pierwszy + drugi), i pod korzeniem 2 drugi łuk powyżej 39 wypycha ją ponad 127, gdzie potrzebuje wielobajtowej formy base-128. Żaden OID SHA-2 nie ćwiczy tej ścieżki, bo 2.16 składa się do 96, więc koder może przejść każdy test SHA-2 i być wciąż zły. Provider zapisuje formę wielobajtową i zgłasza zniekształcony OID własnym EHPDFCSCSignatureProviderError zamiast EConvertError

Dlaczego ponowione żądanie nie produkuje drugiego podpisu?

THPDFCSCSignatureProvider daje każdemu ponawialnemu wywołaniu deterministyczny klucz idempotencji i cacheuje skończone wyniki, więc ponowienie po zgubionej odpowiedzi zwraca oryginalne podpisy zamiast prosić HSM o nowe. Klucz to csc- po czym hex SHA-256 identyfikatora operacji i fazy protokołu, a dla autoryzacji i signHash faza zawiera odcisk partii. Haszowanie zamiast obcinania trzyma klucz stałej długości i blokuje kolizje dwóch długich ID operacji ze wspólnym prefiksem

Polityka ponowień we wspólnej ścieżce żądań jest wąska:

  • HTTP 401 wymusza dokładnie jedno odświeżenie tokena przez callback access-tokena, i żądanie jest powtarzane raz; druga 401 jest finalna, a bez callbacka tokena pierwsza już jest
  • Inne odpowiedzi 4xx i ctsPermanentFailure kończą wywołanie z spsProviderError, a error_description (albo error) usługi ląduje w LastError
  • 408, 429, 5xx i ctsTemporaryFailure są ponawiane do RetryLimit (domyślnie 2), z czekaniem na Retry-After albo RetryBaseDelayMS (100 ms) podwajane przy każdej próbie, z sufitem MaxRetryAfterMS (5 000 ms)
  • Czekania biegą w plasterkach 25 ms sprawdzających Cancel, więc użytkownik, który przerwał, nie siedzi przez pięciosekundowy back-off
  • signHash jest ponawiany tylko przy włączonym EnableIdempotency; wyłącz to, a timeout po wysłaniu jest finalny, bo nikt nie umie powiedzieć, czy hash był już podpisany
Diagram polityki ponowień w HotPDF: każde ponawialne wywołanie niesie deterministyczny klucz idempotencji csc- haszowany z identyfikatora operacji i fazy, HTTP 401 wymusza dokładnie jedno odświeżenie tokenu, inne odpowiedzi 4xx kończą się spsProviderError, a 408, 429, 5xx albo przejściowa porażka transportu są ponawiane do RetryLimit równego 2, z oczekiwaniem na Retry-After albo wykładniczy back-off z limitem 5 000 ms
Skończone partie są cacheowane po identyfikatorze operacji, poświadczeniu i odcisku, a w trybie asynchronicznym zapisany responseID pozwala powtórzonemu wywołaniu wrócić do pollowania, zamiast wysyłać hash od nowa

Podpisywanie asynchroniczne (operationMode „A”, domyślne, z validity_period równym AsyncValidityPeriodMS, 60 000 ms) dokłada jedną straż więcej. responseID jest zapisywany, zanim provider ruszy pollować signatures/signPolling, więc powtórzone wywołanie z tym samym identyfikatorem operacji podnosi oczekującą pracę zamiast wysyłać hash ponownie. Skończone partie są cacheowane per instancja providera, do MaxOperationCacheEntries (128), i ten cache znika po restarcie. Klucz idempotencji przeżywa, bo jest wyprowadzany, a nie losowy: zrestartowany proces, który reużywa swój identyfikator operacji, wysyła ten sam klucz, a to, czy usługa deduplikuje po nim, jest częścią kontraktu usługi

Całe to wisi na identyfikatorze operacji, a parametr łatwo zostawić pustym. Podaj '' do SignHashBatch, a provider spada z powrotem na OperationIdentifier żądań, potem na nowy GUID. Nowy GUID na wywołanie znaczy, że Twoja własna pętla ponowień zaczyna nową operację za każdym razem: nowy slot cache, nowy klucz idempotencji i być może drugie pytanie o OTP. Znaczy też, że Provider.Cancel nie ma czego celować, bo Twój kod nigdy nie widział GUID-a. Wyprowadź identyfikator z czegoś, co już trwale zapisujesz, jak numer partii albo ID dokumentu, i zostaw EnableIdempotency na domyślnym True. Jeśli usługa odrzuca nagłówek, zrzuć go w transporcie zamiast wyłączać opcję

Osadzanie podpisu CSC w PDF

Podaj providera do HPDFCMSSignPDFStreamWithProvider razem z certyfikatem końcowym z GetCertificateChain; HotPDF buduje CMS SignedData, a provider podpisuje skrót atrybutów podpisanych. Wejściowy PDF potrzebuje placeholdera /ByteRange i /Contents, który zapisuje THPDFPage.AddSignedSignatureField — dokładnie jak w przepływie podpisywania PAdES w HotPDF — a model providera to ten sam, który stoi za wtykowymi providerami podpisów HotPDF dla 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 wypisuje certyfikat końcowy jako pierwszy
  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;

Do wymiarowania placeholdera EstimateSignatureSize zwraca EstimatedSignatureBytes, gdy go ustawisz, w przeciwnym razie rozmiar modułu RSA wyprowadzony z długości klucza w credentials/info. Dla kluczy ECDSA odpowiada spsUnsupported, a estymator placeholdera spada z powrotem na certyfikat, więc ustawienie EstimatedSignatureBytes samodzielnie daje przewidywalny wynik. HPDFCMSSignPDFStreamWithProviderAutoSize przebudowuje i podpisuje ponownie, gdy placeholder okaże się za mały, i robi to tylko dla providerów reklamujących spcSafeSignRetry. THPDFCSCSignatureProvider go reklamuje przy włączonym EnableIdempotency — to kolejny powód, by zostawić ten domyślny w spokoju

Dla PAdES-B-T TimestampDigest prosi tę samą usługę o token RFC 3161 przez signatures/timestamp, z opcjonalnym nonce i sufitem rozmiaru MaxTimestampBytes (1 MB)

Decyzje dotyczące polityki, które zostają w Twoim kodzie

Provider podpisuje hashe. Ed25519 i Ed448 w trybie pure wręczają providerowi podpisu całą wiadomość atrybutów podpisanych (sikMessage), a walidator partii odrzuca to jako zniekształcone, bo signHash jest z definicji oparty o hash. Pod Free Pascal jednostka providera kompiluje z gołymi typami funkcji zamiast metod anonimowych, więc SignHashBatch i TimestampDigest są tam wywoływalne; osadzanie wyniku w PDF przez HPDFCMSSignPDFStreamWithProvider to ścieżka Delphi

Provider zostawia też politykę Tobie. CredentialInfo raportuje KeyStatus, CertificateStatus, AuthorizationMode, SCAL i MultiSignatures, ale wyłączony klucz albo poświadczenie SCAL1 to Twoja decyzja o odrzuceniu. Sprawdź te pola zaraz po RefreshCredentialInfo i zanim podpisujący kiedykolwiek zobaczy pytanie o OTP; odmowa od usługi po wpisaniu kodu przez podpisującego to gorsze doświadczenie niż jasny błąd z góry

Jedna instancja providera biegnie jedną partię naraz, bo SignHashBatch, RefreshCredentialInfo i TimestampDigest dzielą lock, więc dwa wątki nie wyścigują się o jedną SAD. Przepustowość bierze się z większych partii, do limitu multisign poświadczenia. Czy wynikowy podpis liczy się jako kwalifikowany, rozstrzyga usługa zaufana i poświadczenie, które wydała

Provider CSC jest dostarczany obok budowniczych CMS i PAdES oraz lokalnych providerów podpisów i PKCS#11 w komponencie HotPDF Delphi PDF, więc podpisywanie zdalne, tokenowe i plikowe wszystkie karmią te same wywołania podpisywania PDF