HotPDF signiert PDF-Dokumente mit einem Private Key, den ein entfernter Cloud Signature Consortium (CSC)-Dienst hält, über THPDFCSCSignatureProvider, einen Signature-Provider, der die CSC-API treibt – Credential-Info, Autorisierung, signatures/signHash und Polling –, während Ihre Delphi-Anwendung die HTTP-Transportschicht und das OAuth-Access-Token liefert. Der Key verlässt das HSM des Dienstes nie
Das ist zunehmend der einzige Weg, überhaupt an einen qualifizierten Signing-Key zu kommen. Trust Service Provider geben einen CSC-Endpunkt und einen OAuth-Client heraus, keine PFX-Datei und keinen USB-Token, es gibt also nichts, was man so in einen lokalen Zertifikatsspeicher laden könnte wie beim Windows-Cert-Store-Signing über CNG und CAPI. Die naive Integration scheitert auf vorhersagbare Weise: Ein signHash-Aufruf läuft in ein Timeout und der Retry signiert denselben Vertrag zweimal, oder ein Stapel von vierzig Rechnungen triggert vierzig Einmalpasswörter, weil jeder Hash separat autorisiert wurde. Der Großteil dessen, was der Provider tut, ist Verteidigung gegen diese zwei Fehler
Warum lässt HotPDF HTTP bei Ihrer Anwendung?
Weil die Transportschicht genau die Stelle ist, an der sich jede Bereitstellung unterscheidet. Proxys, TLS-Pinning, Client-Zertifikate, unternehmenseigene OAuth-Vaults und Logging-Policy wohnen alle in der HTTP-Schicht, also orchestriert THPDFCSCSignatureProvider den Protokollzustand und ruft für jede Anfrage eine THPDFCSCTransport-Funktion auf. Der Provider reicht Ihnen ein THPDFCSCTransportRequest mit Method (immer POST), der vollständigen, aus ServiceBaseURL plus Endpunktpfad gebauten URL, einem fertigen Authorization-Bearer-Header, ContentType, dem JSON-Body, einem IdempotencyKey, der Attempt-Nummer und MaxResponseBytes. Sie füllen ein THPDFCSCTransportResponse mit StatusCode, Body und RetryAfterMS und geben eines von ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure oder ctsCancelled zurück
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 wie Ihr Dienst ihn dokumentiert
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- oder DNS-Problem: wiederholbar
end;
Response.StatusCode := HttpResp.StatusCode; // 503 unverändert melden, nicht klassifizieren
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;
Die eine Regel, die es zu merken lohnt: Geben Sie ctsSuccess zurück, wann immer ein Server tatsächlich geantwortet hat, auch mit einer 503. Der Provider klassifiziert Statuscodes selbst, und eine Transportschicht, die eine 429 in ctsPermanentFailure verwandelt, schaltet die unten beschriebene Retry-Logik still ab. Der Konstruktor ist in die andere Richtung streng – er wirft ein EHPDFCSCSignatureProviderError, wenn die Transportschicht fehlt, CredentialID leer ist, weder ein AccessToken noch ein Token-Callback geliefert wird, ein Budget außerhalb des Bereichs liegt oder ServiceBaseURL kein HTTPS ist. Pures http:// wird nur mit AllowInsecureHTTP akzeptiert, was in einen Prüfstand gehört und sonst nirgends hin
Was ist die SAD, und warum wirft HotPDF sie nach einmaliger Nutzung weg?
THPDFCSCSignatureProvider behandelt die Signature Activation Data (SAD) als einmal nutzbar: Sie wird im Moment, in dem signatures/signHash akzeptiert wird, aus dem Provider-Zustand gelöscht, selbst wenn die Signatur selbst später über asynchrones Polling eintrifft. Die SAD ist der Beweis des Dienstes, dass der Unterzeichner genau diese Hashes gebilligt hat, und eine SAD, die im Speicher herumliegt, ist eine Autorisierung, die darauf wartet, für das falsche Dokument ausgegeben zu werden
Mit den Defaults aus THPDFCSCOptions.Default – RequireSAD und AutoAuthorize beide True – lädt der Provider credentials/info einmal, fragt Ihren THPDFCSCAuthenticationCallback nach den authData-Werten (ein OTP, eine PIN, was auch immer der auth-Block der Credential verlangt) und postet credentials/authorize. Eine 200 trägt die SAD direkt; eine 202 trägt ein Handle, das über credentials/authorizeCheck bis zu MaxPollAttempts (60)-mal im Abstand von PollIntervalMS (250 ms) gepollt wird. Der Callback darf höchstens 32 Werte zurückgeben, jeden mit einer nicht leeren ID von bis zu 256 Bytes und einem Wert von bis zu 4.096 Bytes. Trennt das Netz, bevor signHash akzeptiert ist, wird eine automatisch bezogene SAD aufbewahrt, sodass derselbe Stapel ohne erneutes Fragen des Unterzeichners wiederholt werden kann
var
Options: THPDFCSCOptions;
Provider: THPDFCSCSignatureProvider;
begin
Options := THPDFCSCOptions.Default; // RequireSAD, AutoAuthorize, Async-Modus, 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
// Ihr OAuth-Client; ForceRefresh ist True, nachdem der Dienst mit 401 geantwortet hat
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 // Ihre UI
Exit(spsCancelled);
SetLength(Values, 1);
Values[0].ID := 'otp';
Values[0].Value := AnsiString(Otp);
Result := spsValid;
end);
Eine SAD, die Sie selbst über Options.SAD übergeben, verhält sich anders, und zwar mit Absicht. HotPDF kann nicht wissen, wofür sie ausgestellt wurde, also verwendet der Provider eine voreingestellte SAD nur für eine Einzel-Hash-Anfrage. Bei einem Stapel mit ausgeschaltetem AutoAuthorize scheitert der Provider mit „CSC SAD is not pinned to the requested hash batch“, statt zu raten
Wie signiert SignHashBatch viele Dokumente mit einer Autorisierung?
SignHashBatch schickt ein credentials/authorize und ein signatures/signHash für bis zu MaxBatchSignatures (64) Digests und baut beide Bodies aus demselben Array, sodass numSignatures, die Reihenfolge von hashes und hashAlgorithmOID in den beiden Aufrufen identisch sind. Diese Übereinstimmung verlangt das CSC-Multisign-Modell. Die Ein-Hash-Sign-Methode vierzigmal zu loopen gibt Ihnen vierzig Autorisierungen; ein authorize und ein signHash zu schicken, die nicht zusammenpassen, lässt den Dienst die SAD gegen den falschen Stapel verbrauchen
Bevor irgendein Netzverkehr läuft, validiert der Provider den Stapel. Jede Anfrage muss ein Digest (sikDigest) von 1 bis 1.024 Bytes mit einem Digest-OID sein, und alle Anfragen müssen sich einen Signaturalgorithmus-OID, einen Digest-OID und, bei RSASSA-PSS, eine Salt-Länge teilen. Ein Multi-Hash-Stapel lädt außerdem credentials/info und gibt spsUnsupported zurück, wenn der multisign-Wert der Credential kleiner als der Stapel ist. Die SAD wird dann an einen Stapel-Fingerprint genagelt – ein SHA-256 über ein Versionslabel, die Anzahl und, pro Anfrage, Algorithmus-OID, Digest-OID, Algorithmus, Salt-Länge und Digest-Bytes, jeweils mit Längenpräfix. Zwei Hashes zu tauschen ergibt einen anderen Stapel, der eine frische Autorisierung braucht
var
Requests: THPDFCSCSignatureRequests;
Signatures: THPDFCSCSignatures;
Status: THPDFSignatureProviderStatus;
I: Integer;
begin
SetLength(Requests, Length(Digests)); // Digests: SHA-256-Werte, die Sie berechnet haben
for I := 0 to High(Digests) do
begin
Requests[I] := Default(THPDFSignatureProviderRequest);
Requests[I].Algorithm := hsaRSAPKCS1v15; // signAlgo wird abgeleitet, wenn AlgorithmOID leer ist
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] gehört zu Digests[I]; die Anzahl wurde gegen die Anfrage geprüft
end;
Für RSASSA-PSS schickt der Provider auch signAlgoParams, eine base64-DER-RSASSA-PSS-params-Struktur mit Hash-Algorithmus, MGF1 und Salt-Länge. Sie zu bauen bedeutet, OIDs zu kodieren, und Version 2.748.5 hat eine Ecke davon repariert: X.690 §8.19.4 faltet die ersten zwei Bögen in einen Wert zusammen (40 × erster + zweiter), und unter der 2-Wurzel drückt ein zweiter Bogen über 39 diesen Wert über 127, wo er die Base-128-Multibyte-Form braucht, die frühere Builds nicht angewandt haben. Kein SHA-2-OID ist betroffen – 2.16 faltet zu 96 –, aber ein missgestalteter OID wirft jetzt den eigenen Fehler des Providers statt eines EConvertError
Warum erzeugt eine wiederholte Anfrage keine zweite Signatur?
THPDFCSCSignatureProvider lässt jede wiederholbare Anfrage einen deterministischen Idempotency-Key tragen und cacht abgeschlossene Ergebnisse, ein Retry nach einer verlorenen Antwort gibt also die originalen Signaturen zurück, statt das HSM nach neuen zu fragen. Der Key ist csc- gefolgt vom Hex-SHA-256 des Operationsidentifikators und der Phase, und die Phase bettet den Stapel-Fingerprint sowohl für die Autorisierung als auch für signHash ein. Hashen statt Abschneiden zählt: Zwei lange Operations-IDs, die ein Präfix teilen, würden unter Abschneiden kollidieren, während ein fest langen, inhaltsadressierten Key einzigartig und stabil über Versuche hinweg bleibt
Die Retry-Policy im gemeinsamen Anfragepfad ist absichtlich eng:
- HTTP 401 erzwingt genau ein Token-Refresh über den Access-Token-Callback, dann wird die Anfrage einmal wiederholt, sofern ein Access-Token-Callback zugewiesen ist; eine zweite 401 ist endgültig
- Andere 4xx-Antworten und
ctsPermanentFailurebeenden den Aufruf mitspsProviderError, und daserror_descriptiondes Dienstes landet inLastError - 408, 429, 5xx und
ctsTemporaryFailurewerden bis zuRetryLimit(Default 2) wiederholt, wartend aufRetry-AfteroderRetryBaseDelayMS× 2attempt (100-ms-Basis), gedeckelt beiMaxRetryAfterMS(5.000 ms) - Wartezeiten laufen in 25-ms-Scheiben, die
Cancelprüfen, ein Nutzer, der abbricht, sitzt also kein fünfsekündiges Back-off aus signHashwird nur wiederholt, solangeEnableIdempotencyan ist; schalten Sie es aus, ist ein Timeout nach dem Absenden endgültig, denn niemand kann mehr sagen, ob der Key schon benutzt war
Asynchrones Signieren (operationMode „A“, der Default) fügt eine weitere Absicherung hinzu: Die responseID wird gespeichert, bevor signatures/signPolling gepollt wird, ein wiederholter Aufruf mit demselben Operationsidentifikator setzt das Polling also fort, statt neu einzureichen. Abgeschlossene Stapel liegen in einem Cache, keyed nach Operationsidentifikator, Credential und Fingerprint, gedeckelt durch MaxOperationCacheEntries (128) und als Deep Copies zurückgegeben. Dieser Cache lebt in der Provider-Instanz und überlebt keinen Neustart. Der Idempotency-Key überlebt ihn, denn er ist abgeleitet statt zufällig, ein neu gestarteter Prozess, der seinen Operationsidentifikator wiederverwendet, schickt also denselben Key – ob der Dienst darauf dedupliziert, ist das Versprechen des Dienstes, nicht das von HotPDF
Wie kommt eine CSC-Signatur in eine PDF?
Übergeben Sie den Provider an HPDFCMSSignPDFStreamWithProvider, zusammen mit dem End-Entity-Zertifikat aus GetCertificateChain; HotPDF baut das CMS SignedData, und der Provider signiert den Digest der signed Attributes. Die Eingabe-PDF braucht den /ByteRange- und /Contents-Platzhalter, den THPDFPage.AddSignedSignatureField schreibt, exakt wie im PAdES-Signing-Workflow in HotPDF, und das Provider-Modell ist dasselbe wie in HotPDFs pluggable Signature-Providern für ML-DSA und EdDSA beschrieben
var
Chain: THPDFCSCCertificateChain;
SignOpts: THPDFCMSSignOptions;
Src, Dst: TFileStream;
begin
if Provider.RefreshCredentialInfo <> spsValid then
raise Exception.Create(Provider.LastError);
Chain := Provider.GetCertificateChain; // CSC listet das End-Entity-Zertifikat zuerst
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;
Die Bemessung des /Contents-Platzhalters läuft über EstimateSignatureSize, das EstimatedSignatureBytes zurückgibt, wenn Sie es setzen, sonst die RSA-Modulus-Größe aus der Schlüssellänge der Credential. Bei ECDSA setzen Sie EstimatedSignatureBytes selbst, sonst meldet die Schätzung spsUnsupported. Die Auto-Size-Signaturvariante signiert erneut, wenn ein Platzhalter zu klein herauskommt, und tut das nur für Provider, die spcSafeSignRetry bewerben – was THPDFCSCSignatureProvider nur tut, solange EnableIdempotency an ist. Für PAdES-B-T-Workflows fordert TimestampDigest ein Timestamp-Token über denselben Dienst über signatures/timestamp an, gedeckelt bei MaxTimestampBytes (1 MB)
Was tut der CSC-Provider nicht?
Er signiert keine Nachrichten, nur Digests. Ed25519 und Ed448 im Pure-Mode reichen dem Provider die ganze signed-Attributes-Nachricht (sikMessage), und der Stapelvalidator weist das als missgestaltet zurück, denn signHash ist per Definition hash-basiert. Die Provider-Unit kompiliert unter Free Pascal mit schlichten Funktionstypen statt anonymer Methoden, aber die Provider-getriebenen CMS-Builder werfen unter FPC derzeit, das Einbetten einer CSC-Signatur in eine PDF ist also ein Delphi-Pfad
Er entscheidet auch keine Policy. CredentialInfo meldet den Key-Status, den Zertifikatsstatus, den Autorisierungsmodus, den SCAL-Level und das Multisign-Limit, aber der Provider verweigert von sich aus weder einen deaktivierten Key noch eine SCAL1-Credential – prüfen Sie das, bevor Sie einem Unterzeichner den OTP-Prompt zeigen. Und eine Provider-Instanz signiert jeweils einen Stapel: SignHashBatch ist intern serialisiert, zwei Threads können also nicht um eine SAD wetteifern, der Durchsatz kommt also aus Batching, nicht daraus, einen Provider über Worker-Threads zu teilen. Ob die resultierende Signatur qualifiziert ist, hängt vom Trust Service und seiner Credential ab, nicht von der Bibliothek, die den Hash dorthin getragen hat
Der CSC-Provider, die CMS- und PAdES-Builder und die lokalen und PKCS#11-Provider erscheinen alle in der HotPDF Delphi PDF component