Technický článek

HotPDF CSC remote signing: cloudové PDF podpisy v Delphi

HotPDF podepisuje PDF dokumenty privátním klíčem drženým vzdálenou službou Cloud Signature Consortium (CSC) přes THPDFCSCSignatureProvider, signature providera, který řídí CSC API — credential info, autorizaci, signatures/signHash a polling — zatímco vaše Delphi aplikace dodává HTTP transport a OAuth access token. Klíč nikdy neopustí HSM služby

To je čím dál tím jediná cesta, jak k kvalifikovanému podepisovacímu klíči vůbec dojít. Trust service providery rozdávají CSC endpoint a OAuth klienta, ne soubor PFX ani USB token, takže není co načítat do lokálního certificate store, jako to dělá podepisování z Windows cert store přes CNG a CAPI. Naivní integrace selhává předvídatelně: volání signHash timeoutne a retry podepíše tutéž smlouvu dvakrát, nebo dávka čtyřiceti faktur spustí čtyřicet jednorázových hesel, protože každý hash se autorizoval zvlášť. Většina toho, co provider dělá, je obrana proti těm dvěma selháním

Proč nechává HotPDF HTTP na vaší aplikaci?

Protože transport je přesně to místo, kde se každé nasazení liší. Proxy, TLS pinning, klient certifikáty, firemní OAuth vaulty a logovací politika všechno bydlí v HTTP vrstvě, takže THPDFCSCSignatureProvider diriguje stav protokolu a na každý požadavek volá funkci THPDFCSCTransport. Provider vám podá THPDFCSCTransportRequest s Method (vždy POST), plným URL postaveným z ServiceBaseURL plus cestou endpointu, hotovou hlavičkou Authorization bearer, ContentType, JSON Body, IdempotencyKey, číslem Attempt a MaxResponseBytes. Vy naplníte THPDFCSCTransportResponse hodnotami StatusCode, Body a RetryAfterMS a vrátíte jedno z ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure nebo ctsCancelled

Diagram transportní hranice HotPDF CSC: THPDFCSCSignatureProvider diriguje protokol a podá vašemu kódu THPDFCSCTransportRequest s metodou POST, plným URL, hotovou hlavičkou Authorization bearer, JSON body, IdempotencyKey a číslem pokusu, a vy vracíte StatusCode, Body, RetryAfterMS plus jednu ze čtyř hodnot cts status, zatímco klíč nikdy neopustí HSM
Provider si klasifikuje status kódy sám, takže transport, který zodpovězené 503 otočí na permanentní selhání, potichu vypne retry logiku, zatímco proxy a TLS politika zůstávají v kódu, který vlastníte
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  // název hlavičky podle dokumentace vaší služby
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // potíže se socketem nebo DNS: opakovatelné
        end;
        Response.StatusCode := HttpResp.StatusCode;   // hlásit 503 as-is, neklasifikovat
        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;

Jedno pravidlo stojí za zapamatování: vracet ctsSuccess, kdykoli server skutečně odpověděl, i s 503. Provider si klasifikuje status kódy sám a transport, který 429 otočí na ctsPermanentFailure, potichu vypne retry logiku popsanou níže. Konstruktor je přísný opačným směrem — vyhodí EHPDFCSCSignatureProviderError, když transport chybí, CredentialID je prázdné, není dodán ani AccessToken ani token callback, budget je mimo rozsah, nebo ServiceBaseURL není HTTPS. Holé http:// se akceptuje jen s AllowInsecureHTTP, což patří do testovacího rigu a nikam jinam

Co je SAD a proč ji HotPDF po jednom použití zahodí?

THPDFCSCSignatureProvider bere Signature Activation Data (SAD) jako jednorázovou: maže se ze stavu providera v momentě, kdy se signatures/signHash akceptuje, i když samotný podpis dorazí později asynchronním pollingem. SAD je důkazem služby, že podepisující schválil právě tyhle hashe, a SAD visící v paměti je autorizace čekající, až se utratí za špatný dokument

S defaulty z THPDFCSCOptions.Default — RequireSAD i AutoAuthorize obě True — načte provider jednou credentials/info, zeptá se vašeho THPDFCSCAuthenticationCallback na hodnoty authData (OTP, PIN, cokoli, co žádá blok auth credentialu) a pošle credentials/authorize. 200 nese SAD přímo; 202 nese handle, který se polluje přes credentials/authorizeCheck nejvýš MaxPollAttempts (60) krát po PollIntervalMS (250 ms). Callback smí vrátit nejvýš 32 hodnot, každou s neprázdným ID do 256 bajtů a hodnotou do 4 096 bajtů. Když síť vypadne, než se signHash akceptuje, automaticky získaná SAD se podrží, aby se tatáž dávka dala opakovat, aniž by se podepisující ptal znovu

Diagram životního cyklu SAD v HotPDF: s RequireSAD a AutoAuthorize načte provider jednou credentials/info, zeptá se autentizačního callbacku na OTP nebo PIN hodnoty, pošle credentials/authorize, polluje credentials/authorizeCheck nejvýš 60 krát po 250 ms, když je odpověď 202, a maže Signature Activation Data v momentě akceptace signatures/signHash, přičemž získanou SAD drží, když síť vypadla před akceptací
SAD visící v paměti je autorizace čekající, až se utratí za špatný dokument, a přednastavená SAD podaná přes options se použije jen pro požadavek s jedním hashem
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, async mód, 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
      // váš OAuth klient; ForceRefresh je True, když služba odpověděla 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   // vaše UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

SAD, kterou podáte sami přes Options.SAD, se chová jinak, a to záměrně. HotPDF nemůže vědět, pro které hashe se vystavila, takže provider použije přednastavenou SAD jen pro požadavek s jedním hashem. U dávky s vypnutým AutoAuthorize selže provider s „CSC SAD is not pinned to the requested hash batch“ místo hádání

Jak podepisuje SignHashBatch mnoho dokumentů s jednou autorizací?

SignHashBatch pošle jedno credentials/authorize a jedno signatures/signHash pro nejvýš MaxBatchSignatures (64) digestů a staví obě těla ze stejného pole, takže numSignatures, pořadí hashes a hashAlgorithmOID jsou v obou voláních identické. Ta shoda je to, co model CSC multisign vyžaduje. Loopněte single-hash metodu Sign čtyřicetkrát a dostanete čtyřicet autorizací; pošlete authorize a signHash, které si nerozumí, a služba může utratit SAD proti špatné dávce

Před jakýmkoli síťovým provozem validuje provider dávku. Každý požadavek musí být digest (sikDigest) o 1 až 1 024 bajtech s digest OID a všechny požadavky musí sdílet jeden OID podpisového algoritmu, jeden digest OID a u RSASSA-PSS jednu délku saltu. Multi-hash dávka taky načítá credentials/info a vrátí spsUnsupported, když hodnota multisign credentialu je menší než dávka. SAD se pak připíchne na fingerprint dávky — SHA-256 přes label verze, počet a na požadavek OID algoritmu, digest OID, algoritmus, délku saltu a bajty digestu, každé s délkovým prefixem. Vyměňte dva hashe a je to jiná dávka, která potřebuje čerstvou autorizaci

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: SHA-256 hodnoty, které jste spočítali
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo se odvodí, když je AlgorithmOID prázdný
    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] patří Digests[I]; počet se zkontroloval proti požadavku
end;

Pro RSASSA-PSS posílá provider taky signAlgoParams, strukturu RSASSA-PSS-params jako base64 DER s hash algoritmem, MGF1 a délkou saltu. Její stavba znamená kódování OID a verze 2.748.5 opravila jeden kout: X.690 §8.19.4 skládá první dva arkusy do jedné hodnoty (40 × první + druhý) a pod kořenem 2 posune druhý arkus nad 39 tu hodnotu za 127, kde už potřebuje base-128 vícebajtovou formu, kterou dřívější buildy neaplikovaly. Žádný OID SHA-2 se nedotkne — 2.16 se složí na 96 — ale deformovaný OID teď vyhodí vlastní chybu providera místo EConvertError

Proč opakovaný požadavek nevyprodukuje druhý podpis?

THPDFCSCSignatureProvider zařizuje, že každé opakovatelné volání nese deterministický idempotency key a cachuje dokončené výsledky, takže retry po ztracené odpovědi vrátí původní podpisy místo ptaní HSM po nových. Klíč je csc- následované hex SHA-256 z identifikátoru operace a fáze a fáze vkládá fingerprint dávky pro autorizaci i signHash. Hashování místo useknutí záleží: dva dlouhé operation ID sdílející prefix by se pod useknutím srazily, zatímco fixně dlouhý content-addressed klíč zůstává unikátní a stabilní napříč pokusy

Retry politika ve sdílené cestě požadavků je záměrně úzká:

  • HTTP 401 vynutí přesně jednu obnovu tokenu přes access-token callback a pak se požadavek zopakuje jednou, když je access-token callback přiřazen; druhé 401 je konečné
  • Ostatní odpovědi 4xx a ctsPermanentFailure ukončí volání s spsProviderError a error_description služby přistane v LastError
  • 408, 429, 5xx a ctsTemporaryFailure se opakují do RetryLimit (defaultně 2), s čekáním na Retry-After nebo RetryBaseDelayMS × 2pokus (základ 100 ms), zastropované na MaxRetryAfterMS (5 000 ms)
  • Čekání běží po 25 ms plátkách kontrolujících Cancel, takže uživatel, který to přeruší, nesedí pětisekundový back-off
  • signHash se opakuje jen dokud je EnableIdempotency zapnuté; vypněte ho a timeout po odeslání je konečný, protože nikdo nedokáže říct, jestli se klíč už použil
Diagram retry politiky HotPDF: každé opakovatelné volání nese deterministický idempotency key csc- hashovaný z identifikátoru operace a fáze, HTTP 401 vynutí přesně jednu obnovu tokenu, ostatní odpovědi 4xx končí s spsProviderError a 408, 429, 5xx nebo dočasné selhání transportu se opakují do RetryLimit 2, s čekáním na Retry-After nebo exponenciální backoff zastropovaný na 5 000 ms
Dokončené dávky se cachují podle identifikátoru operace, credentialu a fingerprintu a v asynchronním módu uložené responseID dovolí opakovanému volání navázat pollingem místo opětovného odeslání hashe

Asynchronní podepisování (operationMode „A“, default) přidává ještě jednu pojistku: responseID se ukládá před pollingem signatures/signPolling, takže opakované volání s tímtéž identifikátorem operace naváže pollingem místo opětovného odeslání. Dokončené dávky sedí v cache klíčované identifikátorem operace, credentialem a fingerprintem, zastropované MaxOperationCacheEntries (128) a vracené jako deep copies. Ta cache bydlí v instanci providera a nepřežije restart. Idempotency key přežije, protože se odvozuje, ne hází náhodně, takže restartovaný proces, který znovu použije svůj identifikátor operace, pošle týž klíč — jestli na něm služba deduplikuje, je slib služby, ne HotPDF

Jak dostanete CSC podpis do PDF?

Podejte provider do HPDFCMSSignPDFStreamWithProvider spolu s end-entity certifikátem z GetCertificateChain; HotPDF postaví CMS SignedData a provider podepíše digest signed attributes. Vstupní PDF potřebuje placeholder /ByteRange a /Contents, který zapisuje THPDFPage.AddSignedSignatureField, přesně jako v PAdES workflow podepisování v HotPDF, a model providera je týž, jaký pokrývá článek o pluggable signature providers HotPDF pro ML-DSA a EdDSA

var
  Chain: THPDFCSCCertificateChain;
  SignOpts: THPDFCMSSignOptions;
  Src, Dst: TFileStream;
begin
  if Provider.RefreshCredentialInfo <> spsValid then
    raise Exception.Create(Provider.LastError);
  Chain := Provider.GetCertificateChain;   // CSC vyjmenovává end-entity certifikát první
  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;

Velikost placeholderu /Contents jde přes EstimateSignatureSize, který vrátí EstimatedSignatureBytes, když ho nastavíte, a jinak velikost RSA modulu z délky klíče credentialu. Pro ECDSA si EstimatedSignatureBytes nastavte sami, jinak odhad nahlásí spsUnsupported. Auto-size podpisová varianta podepíše znovu, když se placeholder ukáže jako malý, a dělá to jen pro providery hlásící spcSafeSignRetry — což THPDFCSCSignatureProvider dělá jen dokud je EnableIdempotency zapnuté. Pro workflow PAdES-B-T žádá TimestampDigest timestamp token od téže služby přes signatures/timestamp, zastropované na MaxTimestampBytes (1 MB)

Co CSC provider nedělá?

Nepodepisuje zprávy, jen digesty. Ed25519 a Ed448 v pure módu podají providerovi celou zprávu signed attributes (sikMessage) a batch validator ji odmítne jako deformovanou, protože signHash je z definice hash-based. Unit providera se kompiluje pod Free Pascal s obyčejnými typy funkcí místo anonymních metod, ale CMS buildery řízené providerem dnes pod FPC vyhazují, takže vkládání CSC podpisu do PDF je Delphi cesta

Nerozhoduje ani o politice. CredentialInfo hlásí status klíče, status certifikátu, autorizační mód, úroveň SCAL a limit multisign, ale provider sám neodmítne vypnutý klíč ani credential SCAL1 — zkontrolujte to, než ukážete podepisujícímu OTP prompt. A jedna instance providera podepisuje najednou jednu dávku: SignHashBatch je interně serializovaný, takže dvě vlákna si nemůžou zacházet o jednu SAD, což znamená, že throughput pochází z dávkování, ne ze sdílení providera napříč worker vlákny. Jestli výsledný podpis je kvalifikovaný, záleží na trust službě a jejím credentialu, ne na knihovně, která hash tam donesla

CSC provider, CMS a PAdES buildery i lokální a PKCS#11 providery všechny vycházejí v HotPDF Delphi PDF component