Artykuł techniczny

HotPDF CSC: zdalne podpisywanie PDF z Delphi

HotPDF podpisuje dokumenty PDF kluczem prywatnym trzymanym przez zdalną usługę Cloud Signature Consortium (CSC) za pośrednictwem THPDFCSCSignatureProvider, provider podpisu, który prowadzi CSC API — credentials/info, autoryzację, signatures/signHash i polling — podczas gdy twoja aplikacja Delphi dostarcza transport HTTP i token dostępowy OAuth. Klucz nigdy nie opuszcza HSM usługi

Corczęściej to w ogóle jedyny sposób, by dostać kwalifikowany klucz podpisujący. Dostawcy usług zaufania wydaje punkt końcowy CSC i klienta OAuth, a nie plik PFX albo token USB, więc nie ma czego wczytać do lokalnego magazynu certyfikatów tak, jak robi to podpisywanie z magazynu certyfikatów Windows przez CNG i CAPI. Naiwna integracja pada w przewidywalny sposób: wywołanie signHash dostaje timeout i ponowienie podpisuje ten sam kontrakt dwa razy, albo partia czterdziestu faktur odpala czterdzieści jednorazowych haseł, bo każdy hash był autoryzowany osobno. Większość tego, co robi provider, to obrona przed tymi dwiema porażkami

Dlaczego HotPDF zostawia HTTP twojej aplikacji?

Bo transport to dokładnie to miejsce, gdzie każde wdrożenie się różni. Proxy, przypinanie TLS, certyfikaty klienta, firmowe sejfy OAuth i polityka logowania mieszkają w warstwie HTTP, więc THPDFCSCSignatureProvider orkiestruje stan protokołu i woła funkcję THPDFCSCTransport dla każdego żądania. Provider wręcza ci THPDFCSCTransportRequest z Method (zawsze POST), pełnym URL złożonym z ServiceBaseURL plus ścieżki punktu końcowego, gotowym nagłówkiem Authorization bearer, ContentType, JSON-owym Body, IdempotencyKey, numerem Attempt i MaxResponseBytes. Ty wypełniasz THPDFCSCTransportResponse StatusCode, Body i RetryAfterMS i zwracasz jedno z 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
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  // nazwa nagłówka zgodnie z dokumentacją twojej usługi
          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łopot z gniazdem albo DNS: do ponowienia
        end;
        Response.StatusCode := HttpResp.StatusCode;   // raportuj 503 jak leci, nie klasyfikuj
        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;

Jedna reguła warta zapamiętania: zwracaj ctsSuccess, ilekroć serwer faktycznie odpowiedział, nawet z 503. Provider sam klasyfikuje kody statusów, a transport, który zamienia 429 na ctsPermanentFailure, po cichu wyłącza logikę ponowień opisaną niżej. Konstruktor jest rygorystyczny w drugą stronę — rzuca EHPDFCSCSignatureProviderError, gdy brakuje transportu, CredentialID jest puste, nie dostarczono ani AccessToken, ani callbacka tokenu, budżet jest poza zakresem albo ServiceBaseURL nie jest HTTPS. Gołe http:// jest przyjmowane tylko z AllowInsecureHTTP, co należy do stanowiska testowego i nigdzie indziej

Czym jest SAD i dlaczego HotPDF wyrzuca go po jednym użyciu?

THPDFCSCSignatureProvider traktuje Signature Activation Data (SAD) jako jednorazowe: jest czyszczone ze stanu provider w chwili, gdy signatures/signHash zostanie przyjęte, nawet gdy sam podpis przychodzi później przez asynchroniczny polling. SAD to dowód usługi, że podpisujący zaaprobował właśnie te hashe, a SAD zalegający w pamięci to autoryzacja czekająca na wydanie na niewłaściwy dokument

Z domyślnymi ustawieniami z THPDFCSCOptions.Default — RequireSAD i AutoAuthorize oba True — provider wczytuje credentials/info raz, pyta twój THPDFCSCAuthenticationCallback o wartości authData (OTP, PIN, cokolwiek żąda blok auth poświadczenia) i wysyła credentials/authorize. 200 niesie SAD wprost; 202 niesie uchwyt, który jest pollowany przez credentials/authorizeCheck do MaxPollAttempts (60) razy co PollIntervalMS (250 ms). Callback może zwrócić najwyżej 32 wartości, każda z niepustym ID do 256 bajtów i wartością do 4 096 bajtów. Jeśli sieć pada, zanim signHash zostanie przyjęty, automatycznie zdobyte SAD zostaje zachowane, żeby tę samą partię dało się ponowić bez pytania podpisującego jeszcze 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 asynchroniczny, 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
      // twój klient OAuth; ForceRefresh jest True po 401 od usługi
      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 UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

SAD, który przekazujesz sam przez Options.SAD, zachowuje się inaczej i to celowo. HotPDF nie może wiedzieć, dla jakich hashy został wydany, więc provider używa wstępnie ustawionego SAD tylko dla żądania pojedynczego hasha. Dla partii z wyłączonym AutoAuthorize provider pada z „CSC SAD is not pinned to the requested hash batch”, zamiast zgadywać

Jak SignHashBatch podpisuje wiele dokumentów jedną autoryzacją?

SignHashBatch wysyła jedno credentials/authorize i jedno signatures/signHash dla do MaxBatchSignatures (64) skrótów i buduje oba ciała z tej samej tablicy, tak by numSignatures, kolejność hashes i hashAlgorithmOID były identyczne w obu wywołaniach. Ta zgodność jest tym, czego wymaga model multisign CSC. Zapętlij jednorazową metodę Sign czterdzieści razy i dostaniesz czterdzieści autoryzacji; wyślij authorize i signHash, które się nie zgadzają, a usługa może wydać SAD na niewłaściwą partię

Zanim ruszy jakikolwiek ruch sieciowy, provider waliduje partię. Każde żądanie musi być skrótem (sikDigest) od 1 do 1 024 bajtów z OID-em skrótu, a wszystkie żądania muszą dzielić jeden OID algorytmu podpisu, jeden OID skrótu i, dla RSASSA-PSS, jedną długość soli. Partia wielohashowa wczytuje też credentials/info i zwraca spsUnsupported, gdy wartość multisign poświadczenia jest mniejsza od partii. SAD jest potem przypinane do odcisku partii — SHA-256 z etykiety wersji, liczby i, na żądanie, OID algorytmu, OID skrótu, algorytmu, długości soli i bajtów skrótu, każdy z prefiksem długości. Zamień dwa hashe i to inna partia, która potrzebuje świeżej autoryzacji

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: wyliczone przez ciebie wartości SHA-256
  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;
  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] należy do Digests[I]; liczba była sprawdzona względem żądania
end;

Dla RSASSA-PSS provider wysyła też signAlgoParams, strukturę base64 DER RSASSA-PSS-params z algorytmem skrótu, MGF1 i długością soli. Jej zbudowanie znaczy kodowanie OID-ów, a wersja 2.748.5 naprawiła róg tej sprawy: X.690 §8.19.4 składa pierwsze dwa łuki w jedną wartość (40 × pierwszy + drugi), a pod korzeniem 2 drugi łuk powyżej 39 wypycha tę wartość poza 127, gdzie potrzebuje formy wielobajtowej base-128, której wcześniejsze budowy nie stosowały. Żaden OID SHA-2 nie jest dotknięty — 2.16 składa się do 96 — ale zniekształcony OID rzuca teraz własny błąd provider zamiast EConvertError

Dlaczego ponowione żądanie nie produkuje drugiego podpisu?

THPDFCSCSignatureProvider każe każdemu ponawialnemu wywołaniu nieść 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- plus szesnastkowy SHA-256 identyfikatora operacji i fazy, a faza osadza odcisk partii zarówno dla autoryzacji, jak i signHash. Haszowanie zamiast ucinania ma znaczenie: dwa długie ID operacji dzielące prefiks kolidowałyby przy ucinaniu, podczas gdy stałej długości klucz adresowany treścią pozostaje unikalny i stabilny między próbami

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

  • HTTP 401 wymusza dokładnie jedno odświeżenie tokenu przez callback tokenu dostępowego, po czym żądanie jest powtarzane raz, gdy callback tokenu dostępowego jest przypisany; druga 401 jest ostateczna
  • Inne odpowiedzi 4xx i ctsPermanentFailure kończą wywołanie spsProviderError, a error_description usługi ląduje w LastError
  • 408, 429, 5xx i ctsTemporaryFailure są ponawiane do RetryLimit (domyślnie 2), z oczekiwaniem na Retry-After albo RetryBaseDelayMS × 2próby (baza 100 ms), z limitem MaxRetryAfterMS (5 000 ms)
  • Oczekiwania biegną w plastrach po 25 ms sprawdzających Cancel, więc użytkownik, który przerwie, nie siedzi przez pięciosekundowy back-off
  • signHash jest ponawiany tylko przy włączonym EnableIdempotency; wyłącz je, a timeout po wysłaniu jest ostateczny, bo nikt nie potrafi powiedzieć, czy klucz został już użyty
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

Asynchroniczne podpisywanie (operationMode „A”, domyślny) dokłada jedną strażnicę: responseID jest zapisywane przed pollowaniem signatures/signPolling, więc powtórzone wywołanie z tym samym identyfikatorem operacji wraca do pollowania, zamiast wysyłać od nowa. Skończone partie siedzą w cache kluczowanym identyfikatorem operacji, poświadczeniem i odciskiem, z limitem MaxOperationCacheEntries (128) i zwracane jako głębokie kopie. Ten cache żyje w instancji provider i nie przeżywa restartu. Klucz idempotencji przeżywa, bo jest wyprowadzany, a nie losowy, więc zrestartowany proces, który reużywa swojego identyfikatora operacji, wysyła ten sam klucz — to, czy usługa deduplikuje po nim, to obietnica usługi, nie HotPDF

Jak włożyć podpis CSC do PDF?

Przekaż provider do HPDFCMSSignPDFStreamWithProvider razem z certyfikatem końcowym z GetCertificateChain; HotPDF buduje CMS SignedData, a provider podpisuje skrót podpisanych atrybutów. Wejściowy PDF potrzebuje placeholderów /ByteRange i /Contents, które pisze THPDFPage.AddSignedSignatureField, dokładnie jak w przepływie podpisywania PAdES w HotPDF, a model provider jest ten sam, co opisany w artykule o podłączanych providerach podpisu 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;

Wymiarowanie placeholdera /Contents idzie przez EstimateSignatureSize, które zwraca EstimatedSignatureBytes, gdy go ustawisz, a w przeciwnym razie rozmiar modulusa RSA z długości klucza poświadczenia. Dla ECDSA ustaw EstimatedSignatureBytes sam, albo estymacja zgłosi spsUnsupported. Wariant podpisu z autoskalowaniem podpisuje ponownie, gdy placeholder okaże się za mały, i robi to tylko dla provider reklamujących spcSafeSignRetry — co THPDFCSCSignatureProvider robi tylko przy włączonym EnableIdempotency. Dla przepływów PAdES-B-T TimestampDigest prosi tę samą usługę o token znacznika czasu przez signatures/timestamp, z limitem MaxTimestampBytes (1 MB)

Czego provider CSC nie robi?

Nie podpisuje wiadomości, tylko skróty. Ed25519 i Ed448 w trybie pure wręczają provider całą wiadomość podpisanych atrybutów (sikMessage), a walidator partii odrzuca to jako zniekształcone, bo signHash jest z definicji oparty na hashach. Jednostka provider kompiluje się pod Free Pascal ze zwykłymi typami funkcyjnymi zamiast metod anonimowych, ale buildery CMS sterowane providerem rzucają dziś pod FPC, więc osadzenie podpisu CSC w PDF to ścieżka Delphi

Nie decyduje też o polityce. CredentialInfo raportuje status klucza, status certyfikatu, tryb autoryzacji, poziom SCAL i limit multisign, ale provider sam z siebie nie odmówi wyłączonego klucza ani poświadczenia SCAL1 — sprawdź to, zanim pokażesz podpisującemu prompt o OTP. I jedna instancja provider podpisuje jedną partię naraz: SignHashBatch jest serializowany wewnętrznie, więc dwa wątki nie ścigają się o jedno SAD, co znaczy, że przepustowość bierze się z partii, a nie ze współdzielenia provider między wątkami roboczymi. To, czy wynikowy podpis jest kwalifikowany, zależy od usługi zaufania i jej poświadczenia, nie od biblioteki, która zawiozła tam hash

Provider CSC, buildery CMS i PAdES oraz providery lokalny i PKCS#11 trafiają razem do komponentu PDF HotPDF dla Delphi