Technical Article

HotPDF CSC Remote Signing: Cloud PDF Signatures in Delphi

A signatures/signHash request to a remote signing service can time out after the HSM has already produced the signature, and the client cannot tell which side of that line it landed on. Retry blindly and one approval may yield two signatures on the same contract. Refuse to retry and a nightly batch dies on a single 503. Sign forty invoices one hash at a time and the signer types forty one-time passwords

Any Cloud Signature Consortium (CSC) integration has to solve both. Trust service providers issuing qualified keys increasingly hand out a CSC API endpoint and an OAuth client instead of a PFX file or a USB token, so there is nothing to import into a local store the way Windows cert store signing through CNG and CAPI works. The key stays in the provider's HSM, and every signature is a round trip over HTTPS

Why does HotPDF leave HTTP to your application?

HotPDF signs through a remote CSC service with THPDFCSCSignatureProvider, a signature provider that runs the CSC v2 protocol (credential info, authorisation, signatures/signHash, polling) while your Delphi code owns the HTTP transport and the OAuth token. Proxies, TLS pinning, client certificates, token vaults and request logging all live in the HTTP layer, and they differ in every deployment

For every request the provider builds a THPDFCSCTransportRequest and calls your THPDFCSCTransport function. The record carries the full URL, a ready Authorization bearer header, the JSON Body, an IdempotencyKey, the OperationIdentifier and the Attempt number. Your function fills a THPDFCSCTransportResponse with StatusCode, Body and RetryAfterMS, then returns 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

A transport built on the RTL's THTTPClient needs about forty lines. The body goes out as raw bytes, so the JSON never passes through a code-page conversion:

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   // 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);            // DNS, TLS or socket trouble: retryable
        end;
        Response.StatusCode := HttpResp.StatusCode; // pass 429 and 503 through untouched
        SetLength(Response.Body, Reply.Size);
        if Reply.Size > 0 then
          Move(Reply.Memory^, Response.Body[1], Reply.Size);
        // Retry-After in seconds; an HTTP-date value falls back to 0 here
        Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
        Result := ctsSuccess;
      finally
        Reply.Free;
        Body.Free;
      end;
    end;
end;

The one rule to memorise: return ctsSuccess whenever a server answered, even with a 503. The provider classifies status codes itself, and a transport that maps a 429 to ctsPermanentFailure switches off the retry logic described below. Bodies larger than MaxResponseBytes (2 MB by default) are rejected with spsMalformed whatever the transport returns

The constructor is strict in the other direction. It raises EHPDFCSCSignatureProviderError when the transport is missing, ServiceBaseURL or CredentialID is empty, neither an AccessToken nor a token callback is supplied, a budget is out of range, or the URL is not HTTPS. Plain http:// passes only with AllowInsecureHTTP set, and that flag belongs in a test rig pointed at a local mock service

What is the SAD, and why is it single-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. One that lingers in memory is an authorisation 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 the credential's auth block demands, usually an OTP or a PIN, and posts credentials/authorize. A 200 answer carries the SAD directly. A 202 carries a handle, which the provider polls through credentials/authorizeCheck up to MaxPollAttempts (60) times at PollIntervalMS (250 ms) unless the service sends Retry-After. 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

The clearing happens after acceptance. If the network drops before signHash is accepted, an automatically obtained SAD stays in place, still tied to the same batch, so the retry goes out without a second OTP prompt. The signer approved once; the provider spends that approval once

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 authorisation 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
      // TokenVault is your own OAuth client; ForceRefresh is True after a 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 own UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

The token callback fires when the cached token is missing or within TokenRefreshSkewSeconds (30) of the expiry you reported; an ExpiresAtUTC of zero means the token is reused until a 401 forces a refresh

A SAD you pass in yourself through Options.SAD behaves differently, and on purpose. Nothing tells the provider which hashes that SAD covers, so a preset SAD is used only for a single-hash request. For a multi-hash batch with AutoAuthorize switched on it discards the preset value and authorises the batch; with AutoAuthorize off it fails with "CSC SAD is not pinned to the requested hash batch" instead of guessing. Services that authorise through the OAuth scope alone and never issue a SAD work with RequireSAD set to False, in which case the SAD member is left out of the signHash body

Signing forty invoices with one OTP prompt

SignHashBatch sends one credentials/authorize and one signatures/signHash for up to MaxBatchSignatures (64) digests, and builds both bodies from the same array, so numSignatures, the order of hashes and hashAlgorithmOID agree between the two calls. Loop the single-hash Sign method forty times and you get forty authorisations. Send an authorise and a signHash that disagree and the service may consume the SAD against the wrong batch

Validation runs before any network traffic. Every request has to be a digest (sikDigest) of 1 to 1,024 bytes with a digest OID, and all requests must share one signature algorithm, 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 size. The authorisation is then bound to an order-sensitive SHA-256 fingerprint of the request list, so swapping two hashes makes a different batch that needs a fresh approval

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  OpId: AnsiString;
  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;

  // Derive the ID from your own run record, never from a fresh GUID per call
  OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
  Status := Provider.SignHashBatch(Requests, OpId, Signatures);
  if Status = spsProviderError then
    // Same OpId: a completed batch comes back from the cache, a pending
    // async job resumes polling, an unsent request reuses the same key
    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] belongs to Digests[I]; the count was checked against the request
end;

For RSASSA-PSS the provider adds signAlgoParams, a base64 DER RSASSA-PSS-params structure carrying the hash algorithm, MGF1 and salt length. X.690 §8.19.4 folds the first two OID arcs into one value (40 × first + second), and under the 2 root a second arc above 39 pushes it past 127, where it needs the base-128 multi-byte form. No SHA-2 OID exercises that path, since 2.16 folds to 96, so an encoder can pass every SHA-2 test and still be wrong. The provider writes the multi-byte form and reports a malformed OID as its own EHPDFCSCSignatureProviderError rather than an EConvertError

Why does a retried request not produce a second signature?

THPDFCSCSignatureProvider gives every retryable call 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 protocol phase, and for authorisation and signHash the phase includes the batch fingerprint. Hashing instead of truncating keeps the key a fixed length and stops two long operation IDs with a shared prefix from colliding

The retry policy in the shared request path is narrow:

  • HTTP 401 forces exactly one token refresh through the access-token callback, and the request is repeated once; a second 401 is final, and without a token callback the first one is
  • Other 4xx responses and ctsPermanentFailure end the call with spsProviderError, and the service's error_description (or error) lands in LastError
  • 408, 429, 5xx and ctsTemporaryFailure are retried up to RetryLimit (default 2), waiting for Retry-After or RetryBaseDelayMS (100 ms) doubled on each attempt, 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 hash was already signed
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, with a validity_period of AsyncValidityPeriodMS, 60,000 ms) adds one more guard. The responseID is stored before the provider starts polling signatures/signPolling, so a repeated call with the same operation identifier picks up the pending job instead of submitting the hash again. Completed batches are cached per provider instance, up to MaxOperationCacheEntries (128), and that cache is gone after a restart. The idempotency key survives, because it is derived instead of random: a restarted process that reuses its operation identifier sends the same key, and whether the service deduplicates on it is part of the service's contract

All of this hinges on the operation identifier, and the parameter is easy to leave empty. Pass '' to SignHashBatch and the provider falls back to the OperationIdentifier on the requests, then to a new GUID. A new GUID per call means your own retry loop starts a new operation every time: new cache slot, new idempotency key, and possibly a second OTP prompt. It also means Provider.Cancel has nothing to target, since your code never saw the GUID. Derive the identifier from something you already persist, such as the batch number or document ID, and keep EnableIdempotency at its default of True. If a service rejects the header, drop it in the transport instead of turning the option off

Embedding a CSC signature in 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 behind 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;

For placeholder sizing, EstimateSignatureSize returns EstimatedSignatureBytes when you set it, otherwise the RSA modulus size derived from the key length in credentials/info. For ECDSA keys it answers spsUnsupported and the placeholder estimator falls back to the certificate, so setting EstimatedSignatureBytes yourself gives a predictable result. HPDFCMSSignPDFStreamWithProviderAutoSize rebuilds and re-signs when a placeholder turns out too small, and it does so only for providers that advertise spcSafeSignRetry. THPDFCSCSignatureProvider advertises it while EnableIdempotency is on, which is one more reason to leave that default alone

For PAdES-B-T, TimestampDigest asks the same service for an RFC 3161 token through signatures/timestamp, with an optional nonce and a size cap of MaxTimestampBytes (1 MB)

Policy decisions that stay in your code

The provider signs digests. Ed25519 and Ed448 in pure mode hand a signature provider the whole signed-attributes message (sikMessage), and the batch validator rejects that as malformed, because signHash is hash-based by definition. Under Free Pascal the provider unit compiles with plain function types in place of anonymous methods, so SignHashBatch and TimestampDigest are callable there; embedding the result in a PDF through HPDFCMSSignPDFStreamWithProvider is a Delphi path

The provider also leaves policy to you. CredentialInfo reports KeyStatus, CertificateStatus, AuthorizationMode, SCAL and MultiSignatures, but a disabled key or a SCAL1 credential is your call to reject. Check those fields right after RefreshCredentialInfo and before the signer ever sees an OTP prompt; a refusal from the service after the signer typed a code is a worse experience than a clear error up front

One provider instance runs one batch at a time, because SignHashBatch, RefreshCredentialInfo and TimestampDigest share a lock so two threads cannot race for one SAD. Throughput comes from bigger batches, up to the credential's multisign limit. Whether the resulting signature counts as qualified is decided by the trust service and the credential it issued

The CSC provider ships alongside the CMS and PAdES builders and the local and PKCS#11 signature providers in the HotPDF Delphi PDF component, so remote, token-based and file-based signing all feed the same PDF signing calls