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
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
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
ctsPermanentFailureend the call withspsProviderError, and the service'serror_descriptionlands inLastError - 408, 429, 5xx and
ctsTemporaryFailureare retried up toRetryLimit(default 2), waiting forRetry-AfterorRetryBaseDelayMS× 2attempt (100 ms base), capped atMaxRetryAfterMS(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 signHashis retried only whileEnableIdempotencyis on; switch it off and a timeout after submission is final, because nobody can tell whether the key was already used
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