Ein signatures/signHash-Request an einen Remote-Signing-Dienst kann nach einer Zeitüberschreitung enden, obwohl das HSM die Signatur bereits erzeugt hat, und der Client kann nicht sagen, auf welcher Seite der Linie er gelandet ist. Blind zu retryen heißt: Eine Freigabe kann zwei Signaturen auf demselben Vertrag erzeugen. Sich zu weigern zu retryen heißt: Ein nächtlicher Batch stirbt an einer einzigen 503. Vierzig Rechnungen Hash für Hash zu signieren heißt: Der Unterzeichner tippt vierzig Einmalpasswörter
Jede Cloud Signature Consortium (CSC)-Integration muss beides lösen. Trust Service Provider, die qualifizierte Keys ausgeben, reichen zunehmend einen CSC-API-Endpoint und einen OAuth-Client heraus statt einer PFX-Datei oder eines USB-Tokens; es gibt also nichts, was man wie beim Windows-Zertifikatsspeicher-Signieren über CNG und CAPI in einen lokalen Speicher importieren könnte. Der Key bleibt im HSM des Providers, und jede Signatur ist ein Rundlauf über HTTPS
Warum lässt HotPDF HTTP bei Ihrer Anwendung?
HotPDF signiert über einen entfernten CSC-Dienst mit THPDFCSCSignatureProvider, einem Signatur-Provider, der das CSC-v2-Protokoll fährt (Credential Info, Autorisierung, signatures/signHash, Polling), während Ihr Delphi-Code den HTTP-Transport und das OAuth-Token besitzt. Proxys, TLS-Pinning, Client-Zertifikate, Token-Tresore und Request-Logging leben alle in der HTTP-Schicht, und sie sehen in jedem Deployment anders aus
Für jeden Request baut der Provider einen THPDFCSCTransportRequest und ruft Ihre THPDFCSCTransport-Funktion. Der Record trägt die volle URL, einen fertigen Authorization-Bearer-Header, den JSON-Body, einen IdempotencyKey, den OperationIdentifier und die Attempt-Nummer. Ihre Funktion füllt einen THPDFCSCTransportResponse mit StatusCode, Body und RetryAfterMS und liefert dann ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure oder ctsCancelled
Eine Transportschicht auf dem RTL-THTTPClient braucht rund vierzig Zeilen. Der Body geht als rohe Bytes hinaus, der JSON passiert also nie eine Codepage-Konvertierung:
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 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); // DNS-, TLS- oder Socket-Ärger: wiederholbar
end;
Response.StatusCode := HttpResp.StatusCode; // 429 und 503 unverändert durchreichen
SetLength(Response.Body, Reply.Size);
if Reply.Size > 0 then
Move(Reply.Memory^, Response.Body[1], Reply.Size);
// Retry-After in Sekunden; ein HTTP-date-Wert fällt hier auf 0 zurück
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: Liefern Sie ctsSuccess, wann immer ein Server geantwortet hat, auch mit einer 503. Der Provider klassifiziert Statuscodes selbst, und eine Transportschicht, die eine 429 auf ctsPermanentFailure mappt, schaltet die unten beschriebene Retry-Logik ab. Bodies größer als MaxResponseBytes (2 MB als Voreinstellung) werden mit spsMalformed zurückgewiesen, was auch immer die Transportschicht liefert
Der Konstruktor ist in die andere Richtung streng. Er wirft ein EHPDFCSCSignatureProviderError, wenn die Transportschicht fehlt, ServiceBaseURL oder CredentialID leer ist, weder ein AccessToken noch ein Token-Callback geliefert wird, ein Budget außerhalb des Bereichs liegt oder die URL kein HTTPS ist. Pures http:// geht nur mit gesetztem AllowInsecureHTTP durch, und dieses Flag gehört in einen Prüfstand, der auf einen lokalen Mock-Dienst zeigt
Was ist die SAD, und warum ist sie zur Einmal-Nutzung da?
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. 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, die der auth-Block der Credential verlangt, üblicherweise ein OTP oder eine PIN, und postet credentials/authorize. Eine 200 trägt die SAD direkt. Eine 202 trägt ein Handle, das der Provider über credentials/authorizeCheck bis zu MaxPollAttempts (60)-mal im Abstand von PollIntervalMS (250 ms) pollt, sofern der Dienst kein Retry-After schickt. 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
Das Löschen passiert nach der Annahme. Trennt das Netz, bevor signHash akzeptiert ist, bleibt eine automatisch bezogene SAD auf ihrem Platz, weiterhin an denselben Batch gebunden, der Retry geht also ohne ein zweites OTP-Prompt hinaus. Der Unterzeichner hat einmal gebilligt; der Provider gibt diese Billigung einmal aus
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);
Der Token-Callback feuert, wenn das gecachte Token fehlt oder innerhalb von TokenRefreshSkewSeconds (30) vor dem gemeldeten Ablauf liegt; ein ExpiresAtUTC von null heißt, das Token wird wiederverwendet, bis eine 401 einen Refresh erzwingt
Eine SAD, die Sie selbst über Options.SAD übergeben, verhält sich anders, und zwar mit Absicht. Nichts sagt dem Provider, welche Hashes diese SAD deckt, also wird eine vorgegebene SAD nur für eine Einzel-Hash-Anfrage verwendet. Bei einem Multi-Hash-Stapel mit eingeschaltetem AutoAuthorize verwirft er den vorgegebenen Wert und autorisiert den Stapel; mit ausgeschaltetem AutoAuthorize scheitert er mit „CSC SAD is not pinned to the requested hash batch", statt zu raten. Dienste, die allein über den OAuth-Scope autorisieren und nie eine SAD ausstellen, arbeiten mit RequireSAD auf False; in dem Fall bleibt das SAD-Mitglied aus dem signHash-Body außen vor
Vierzig Rechnungen mit einem OTP-Prompt signieren
SignHashBatch schickt ein credentials/authorize und ein signatures/signHash für bis zu MaxBatchSignatures (64) Digests und baut beide Bodies aus demselben Array, numSignatures, die Reihenfolge von hashes und hashAlgorithmOID stimmen also zwischen den beiden Aufrufen. Die Einzel-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
Die Validierung läuft, bevor irgendein Netzverkehr stattfindet. 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, einen Digest-OID und, bei RSASSA-PSS, eine Salt-Länge teilen. Ein Multi-Hash-Stapel lädt außerdem credentials/info und liefert spsUnsupported, wenn der multisign-Wert der Credential kleiner als die Stapelgröße ist. Die Autorisierung wird dann an einen reihenfolgesensitiven SHA-256-Fingerprint der Anfrageliste gebunden, zwei Hashes zu tauschen ergibt also einen anderen Stapel, der eine frische Freigabe braucht
var
Requests: THPDFCSCSignatureRequests;
Signatures: THPDFCSCSignatures;
Status: THPDFSignatureProviderStatus;
OpId: AnsiString;
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;
// Die ID aus Ihrem eigenen Lauf-Record ableiten, nie ein frischer GUID pro Aufruf
OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
Status := Provider.SignHashBatch(Requests, OpId, Signatures);
if Status = spsProviderError then
// Dieselbe OpId: Ein abgeschlossener Stapel kommt aus dem Cache, ein laufender
// Async-Job pollt weiter, eine ungesendete Anfrage nutzt denselben 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] gehört zu Digests[I]; die Anzahl wurde gegen die Anfrage geprüft
end;
Für RSASSA-PSS ergänzt der Provider signAlgoParams, eine base64-DER-RSASSA-PSS-params-Struktur mit Hash-Algorithmus, MGF1 und Salt-Länge. X.690 §8.19.4 faltet die ersten zwei OID-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. Kein SHA-2-OID durchläuft diesen Pfad, 2.16 faltet zu 96, ein Encoder kann also jeden SHA-2-Test bestehen und trotzdem falsch sein. Der Provider schreibt die Multibyte-Form und meldet einen missgestalteten OID als eigenes EHPDFCSCSignatureProviderError statt eines EConvertError
Warum erzeugt eine wiederholte Anfrage keine zweite Signatur?
THPDFCSCSignatureProvider gibt jeder wiederholbaren Anfrage einen deterministischen Idempotency-Key mit und cacht abgeschlossene Ergebnisse, ein Retry nach einer verlorenen Antwort liefert also die originalen Signaturen zurück, statt das HSM nach neuen zu fragen. Der Key ist csc- gefolgt vom Hex-SHA-256 aus Operationsidentifikator und Protokollphase, und für Autorisierung wie signHash enthält die Phase den Stapel-Fingerprint. Zu hashen statt abzuschneiden hält den Key auf fester Länge und verhindert, dass zwei lange Operations-IDs mit gemeinsamem Präfix kollidieren
Die Retry-Policy im gemeinsamen Anfragepfad ist eng:
- HTTP 401 erzwingt genau einen Token-Refresh über den Access-Token-Callback, und die Anfrage wird einmal wiederholt; eine zweite 401 ist final, und ohne Token-Callback ist es die erste
- Andere 4xx-Antworten und
ctsPermanentFailurebeenden den Aufruf mitspsProviderError, und daserror_description(odererror) des Dienstes landet inLastError - 408, 429, 5xx und
ctsTemporaryFailurewerden bis zuRetryLimit(Voreinstellung 2) wiederholt, mit Warten aufRetry-AfteroderRetryBaseDelayMS(100 ms), verdoppelt je Versuch, gekappt beiMaxRetryAfterMS(5.000 ms) - Warten läuft in 25-ms-Scheiben, die
Cancelprüfen, ein Nutzer, der abbricht, sitzt also kein Fünf-Sekunden-Backoff durch signHashwird nur wiederholt, solangeEnableIdempotencyan ist; schalten Sie es ab, ist ein Timeout nach der Absendung final, denn niemand kann sagen, ob der Hash schon signiert wurde
Asynchrones Signieren (operationMode „A", der Default, mit einer validity_period von AsyncValidityPeriodMS, 60.000 ms) ergänzt eine weitere Schranke. Die responseID wird gespeichert, bevor der Provider mit dem Pollen von signatures/signPolling beginnt, ein wiederholter Aufruf mit demselben Operationsidentifikator nimmt also den laufenden Job auf, statt den Hash erneut einzureichen. Abgeschlossene Stapel werden pro Provider-Instanz gecacht, bis zu MaxOperationCacheEntries (128), und nach einem Neustart ist dieser Cache weg. Der Idempotency-Key überlebt, denn er ist abgeleitet statt zufällig: Ein neu gestarteter Prozess, der seinen Operationsidentifikator wiederverwendet, schickt denselben Key, und ob der Dienst darauf dedupliziert, gehört zum Vertrag des Dienstes
Das alles hängt am Operationsidentifikator, und der Parameter lädt dazu ein, leer zu bleiben. Gibt man '' an SignHashBatch, greift der Provider auf den OperationIdentifier der Requests zurück, dann auf einen neuen GUID. Ein neuer GUID pro Aufruf heißt: Ihre eigene Retry-Schleife startet jedes Mal eine neue Operation — neuer Cache-Slot, neuer Idempotency-Key, eventuell ein zweites OTP-Prompt. Sie bedeutet auch, dass Provider.Cancel kein Ziel hat, denn Ihr Code hat den GUID nie gesehen. Leiten Sie den Identifikator aus etwas ab, das Sie ohnehin persistieren, etwa der Batch-Nummer oder der Dokument-ID, und lassen Sie EnableIdempotency auf seiner Voreinstellung True. Weist ein Dienst den Header zurück, lassen Sie ihn in der Transportschicht weg, statt die Option abzuschalten
Eine CSC-Signatur in ein PDF einbetten
Reichen Sie den Provider an HPDFCMSSignPDFStreamWithProvider zusammen mit dem End-Entity-Zertifikat aus GetCertificateChain weiter; HotPDF baut die CMS SignedData, und der Provider signiert den Digest der Signed Attributes. Die Eingabe-PDF braucht den /ByteRange- und /Contents-Platzhalter, den THPDFPage.AddSignedSignatureField schreibt, genau wie im PAdES-Signier-Workflow in HotPDF, und das Provider-Modell ist dasselbe hinter HotPDFs steckbaren Signatur-Providern für ML-DSA und EdDSA
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;
Für die Platzhalter-Bemessung liefert EstimateSignatureSize EstimatedSignatureBytes, wenn Sie es setzen, sonst die aus der Key-Länge in credentials/info abgeleitete RSA-Modulus-Größe. Bei ECDSA-Keys antwortet es mit spsUnsupported, und der Platzhalter-Schätzer fällt auf das Zertifikat zurück; EstimatedSignatureBytes selbst zu setzen liefert also ein vorhersagbares Ergebnis. HPDFCMSSignPDFStreamWithProviderAutoSize baut neu und signiert erneut, wenn ein Platzhalter zu klein gerät, und nur für Provider, die spcSafeSignRetry ankündigen. THPDFCSCSignatureProvider kündigt es an, solange EnableIdempotency an ist, ein Grund mehr, diesen Default in Ruhe zu lassen
Für PAdES-B-T fragt TimestampDigest denselben Dienst über signatures/timestamp nach einem RFC-3161-Token, mit optionalem Nonce und einer Größenkappe von MaxTimestampBytes (1 MB)
Policy-Entscheidungen, die in Ihrem Code bleiben
Der Provider signiert Digests. Ed25519 und Ed448 im Pure-Modus reichen einem Signatur-Provider die ganze Signed-Attributes-Nachricht (sikMessage), und der Stapel-Validierer weist das als missgestaltet zurück, denn signHash ist per Definition Hash-basiert. Unter Free Pascal kompiliert die Provider-Unit mit schlichten Funktionstypen an Stelle anonymer Methoden, SignHashBatch und TimestampDigest sind dort also aufrufbar; das Einbetten des Ergebnisses in ein PDF über HPDFCMSSignPDFStreamWithProvider ist ein Delphi-Pfad
Der Provider überlässt auch die Policy Ihnen. CredentialInfo meldet KeyStatus, CertificateStatus, AuthorizationMode, SCAL und MultiSignatures, aber ein deaktivierter Key oder eine SCAL1-Credential zurückzuweisen ist Ihre Entscheidung. Prüfen Sie diese Felder direkt nach RefreshCredentialInfo und bevor der Unterzeichner überhaupt ein OTP-Prompt sieht; eine Zurückweisung durch den Dienst, nachdem der Unterzeichner einen Code getippt hat, ist ein schlechteres Erlebnis als eine klare Fehlermeldung vorab
Eine Provider-Instanz fährt einen Batch zur Zeit, denn SignHashBatch, RefreshCredentialInfo und TimestampDigest teilen sich ein Lock, zwei Threads können also nicht um eine SAD rasen. Durchsatz kommt aus größeren Batches, bis zur multisign-Grenze der Credential. Ob die entstehende Signatur als qualifiziert zählt, entscheiden der Trust Service und die Credential, die er ausgestellt hat
Der CSC-Provider kommt zusammen mit den CMS- und PAdES-Buildern und den lokalen und PKCS#11-Signatur-Providern in der HotPDF Delphi PDF Component, Remote-, Token- und dateibasiertes Signieren speisen also dieselben PDF-Signier-Aufrufe