Technical Article

HotPDF CSC Remote Signing: Cloud PDF Signatures in Delphi

HotPDF signs PDF documents with a private key held by a remote Cloud Signature Consortium (CSC) service through THPDFCSCSignatureProvider, a signature provider that drives the CSC API — credential info, authorization, signatures/signHash and polling — while your Delphi application supplies the HTTP transport and the OAuth access token. The key never leaves the service's HSM

That is increasingly the only way to get a qualified signing key at all. Trust service providers hand out a CSC endpoint and an OAuth client, not a PFX file or a USB token, so there is nothing to load into a local certificate store the way Windows cert store signing through CNG and CAPI does. The naive integration fails in predictable ways: a signHash call times out and the retry signs the same contract twice, or a batch of forty invoices triggers forty one-time passwords because each hash was authorized separately. Most of what the provider does is defend against those two failures

Why does HotPDF leave HTTP to your application?

Because the transport is exactly where every deployment differs. Proxies, TLS pinning, client certificates, corporate OAuth vaults and logging policy all live in the HTTP layer, so THPDFCSCSignatureProvider orchestrates the protocol state and calls a THPDFCSCTransport function for every request. The provider hands you a THPDFCSCTransportRequest with Method (always POST), the full URL built from ServiceBaseURL plus the endpoint path, a ready Authorization bearer header, ContentType, the JSON Body, an IdempotencyKey, the Attempt number and MaxResponseBytes. You fill a THPDFCSCTransportResponse with StatusCode, Body and RetryAfterMS, and return one of ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure or ctsCancelled

HotPDF CSC transport boundary diagram: THPDFCSCSignatureProvider orchestrates the protocol and hands your code a THPDFCSCTransportRequest with a POST method, the full URL, a ready Authorization bearer header, the JSON body, an IdempotencyKey and the attempt number, and you return StatusCode, Body, RetryAfterMS plus one of the four cts status values while the key never leaves the HSM
The provider classifies status codes itself, so a transport that turns an answered 503 into a permanent failure quietly disables the retry logic, while proxies and TLS policy stay in code you own
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  // header name as your service documents it
          Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
            string(Request.IdempotencyKey))];
        try
          HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
        except
          on ENetHTTPClientException do
            Exit(ctsTemporaryFailure);            // socket or DNS trouble: retryable
        end;
        Response.StatusCode := HttpResp.StatusCode;   // report 503 as-is, do not classify
        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;

The one rule worth memorizing: return ctsSuccess whenever a server actually answered, even with a 503. The provider classifies status codes itself, and a transport that turns a 429 into ctsPermanentFailure quietly disables the retry logic described below. The constructor is strict in the other direction — it raises EHPDFCSCSignatureProviderError when the transport is missing, CredentialID is empty, neither an AccessToken nor a token callback is supplied, a budget is out of range, or ServiceBaseURL is not HTTPS. Plain http:// is accepted only with AllowInsecureHTTP, which belongs in a test rig and nowhere else

What is the SAD, and why does HotPDF throw it away after one use?

THPDFCSCSignatureProvider treats the Signature Activation Data (SAD) as single-use: it is cleared from provider state the moment signatures/signHash is accepted, even when the signature itself arrives later through asynchronous polling. The SAD is the service's proof that the signer approved these particular hashes, and a SAD that lingers in memory is an authorization waiting to be spent on the wrong document

With the defaults from THPDFCSCOptions.Default — RequireSAD and AutoAuthorize both True — the provider loads credentials/info once, asks your THPDFCSCAuthenticationCallback for the authData values (an OTP, a PIN, whatever the credential's auth block demands), and posts credentials/authorize. A 200 carries the SAD directly; a 202 carries a handle that is polled through credentials/authorizeCheck up to MaxPollAttempts (60) times at PollIntervalMS (250 ms). The callback may return at most 32 values, each with a non-empty ID of up to 256 bytes and a value of up to 4,096 bytes. If the network drops before signHash is accepted, an automatically obtained SAD is kept so the same batch can be retried without asking the signer again

HotPDF SAD lifecycle diagram: with RequireSAD and AutoAuthorize the provider loads credentials/info once, asks the authentication callback for OTP or PIN values, posts credentials/authorize, polls credentials/authorizeCheck up to 60 times at 250 ms when the answer is 202, and clears the Signature Activation Data the moment signatures/signHash is accepted, keeping an obtained SAD while the network dropped before acceptance
A SAD that lingers in memory is an authorization waiting to be spent on the wrong document, and a preset SAD passed through options is used for a single-hash request only
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, async mode, 2 retries
  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
      // your OAuth client; ForceRefresh is True after the service answered 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   // your UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

A SAD you pass in yourself through Options.SAD behaves differently, and deliberately so. HotPDF cannot know which hashes it was issued for, so the provider uses a preset SAD only for a single-hash request. For a batch with AutoAuthorize switched off, the provider fails with "CSC SAD is not pinned to the requested hash batch" instead of guessing

How does SignHashBatch sign many documents with one authorization?

SignHashBatch sends one credentials/authorize and one signatures/signHash for up to MaxBatchSignatures (64) digests, and builds both bodies from the same array so that numSignatures, the order of hashes and hashAlgorithmOID are identical in the two calls. That match is what the CSC multisign model requires. Loop the single-hash Sign method forty times and you get forty authorizations; send an authorize and a signHash that disagree and the service may consume the SAD against the wrong batch

Before any network traffic, the provider validates the batch. Every request must be a digest (sikDigest) of 1 to 1,024 bytes with a digest OID, and all requests must share one signature algorithm OID, one digest OID and, for RSASSA-PSS, one salt length. A multi-hash batch also loads credentials/info and returns spsUnsupported when the credential's multisign value is smaller than the batch. The SAD is then pinned to a batch fingerprint — a SHA-256 over a version label, the count and, per request, the algorithm OID, digest OID, algorithm, salt length and digest bytes, each length-prefixed. Swap two hashes and it is a different batch that needs a fresh authorization

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: SHA-256 values you computed
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo derived when AlgorithmOID is empty
    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] belongs to Digests[I]; the count was checked against the request
end;

For RSASSA-PSS the provider also sends signAlgoParams, a base64 DER RSASSA-PSS-params structure with the hash algorithm, MGF1 and salt length. Building it means encoding OIDs, and version 2.748.5 fixed a corner of that: X.690 §8.19.4 folds the first two arcs into one value (40 × first + second), and under the 2 root a second arc above 39 pushes that value past 127, where it needs the base-128 multi-byte form that earlier builds did not apply. No SHA-2 OID is affected — 2.16 folds to 96 — but a malformed OID now raises the provider's own error instead of an EConvertError

Why does a retried request not produce a second signature?

THPDFCSCSignatureProvider makes every retryable call carry a deterministic idempotency key and caches completed results, so a retry after a lost response returns the original signatures instead of asking the HSM for new ones. The key is csc- followed by the hex SHA-256 of the operation identifier and the phase, and the phase embeds the batch fingerprint for both authorization and signHash. Hashing instead of truncating matters: two long operation IDs that share a prefix would collide under truncation, while a fixed-length content-addressed key stays unique and stable across attempts

The retry policy in the shared request path is narrow on purpose:

  • HTTP 401 forces exactly one token refresh through the access-token callback, then the request is repeated once when an access-token callback is assigned; a second 401 is final
  • Other 4xx responses and ctsPermanentFailure end the call with spsProviderError, and the service's error_description lands in LastError
  • 408, 429, 5xx and ctsTemporaryFailure are retried up to RetryLimit (default 2), waiting for Retry-After or RetryBaseDelayMS × 2attempt (100 ms base), capped at MaxRetryAfterMS (5,000 ms)
  • Waits run in 25 ms slices that check Cancel, so a user who aborts does not sit through a five-second back-off
  • signHash is retried only while EnableIdempotency is on; switch it off and a timeout after submission is final, because nobody can tell whether the key was already used
HotPDF retry policy diagram: every retryable call carries a deterministic csc- idempotency key hashed from the operation identifier and phase, HTTP 401 forces exactly one token refresh, other 4xx answers end with spsProviderError, and 408, 429, 5xx or a temporary transport failure retry up to RetryLimit of 2 while waiting on Retry-After or exponential backoff capped at 5,000 ms
Completed batches are cached by operation identifier, credential and fingerprint, and in asynchronous mode the stored responseID lets a repeated call resume polling instead of resubmitting the hash

Asynchronous signing (operationMode "A", the default) adds one more guard: the responseID is stored before polling signatures/signPolling, so a repeated call with the same operation identifier resumes polling instead of resubmitting. Completed batches sit in a cache keyed by operation identifier, credential and fingerprint, capped by MaxOperationCacheEntries (128) and returned as deep copies. That cache lives in the provider instance and does not survive a restart. The idempotency key does, because it is derived rather than random, so a restarted process that reuses its operation identifier sends the same key — whether the service deduplicates on it is the service's promise, not HotPDF's

How do you put a CSC signature into a PDF?

Pass the provider to HPDFCMSSignPDFStreamWithProvider together with the end-entity certificate from GetCertificateChain; HotPDF builds the CMS SignedData and the provider signs the digest of the signed attributes. The input PDF needs the /ByteRange and /Contents placeholder that THPDFPage.AddSignedSignatureField writes, exactly as in the PAdES signing workflow in HotPDF, and the provider model is the same one covered in HotPDF pluggable signature providers for ML-DSA and EdDSA

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

Sizing the /Contents placeholder goes through EstimateSignatureSize, which returns EstimatedSignatureBytes when you set it and otherwise the RSA modulus size from the credential's key length. For ECDSA set EstimatedSignatureBytes yourself, or estimation reports spsUnsupported. The auto-size signing variant re-signs when a placeholder turns out too small, and it only does that for providers advertising spcSafeSignRetry — which THPDFCSCSignatureProvider does only while EnableIdempotency is on. For PAdES-B-T workflows, TimestampDigest requests a timestamp token from the same service through signatures/timestamp, capped at MaxTimestampBytes (1 MB)

What does the CSC provider not do?

It does not sign messages, only digests. Ed25519 and Ed448 in pure mode hand the provider the whole signed-attributes message (sikMessage), and the batch validator rejects that as malformed, because signHash is by definition hash-based. The provider unit compiles under Free Pascal with plain function types in place of anonymous methods, but the provider-driven CMS builders raise under FPC today, so embedding a CSC signature in a PDF is a Delphi path

It also does not decide policy. CredentialInfo reports the key status, certificate status, authorization mode, SCAL level and multisign limit, but the provider will not refuse a disabled key or a SCAL1 credential on its own — check those before you show a signer the OTP prompt. And one provider instance signs one batch at a time: SignHashBatch is serialized internally so two threads cannot race for one SAD, which means throughput comes from batching, not from sharing a provider across worker threads. Whether the resulting signature is qualified depends on the trust service and its credential, not on the library that carried the hash there

The CSC provider, the CMS and PAdES builders and the local and PKCS#11 providers all ship in the HotPDF Delphi PDF component