Technischer Artikel

HotPDF CSC Remote Signing: Cloud-PDF-Signaturen in Delphi

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

HotPDF-Diagramm der CSC-Transportgrenze: THPDFCSCSignatureProvider orchestriert das Protokoll und reicht Ihrem Code ein THPDFCSCTransportRequest mit POST-Methode, der vollen URL, einem fertigen Authorization-Bearer-Header, dem JSON-Body, einem IdempotencyKey und der Attempt-Nummer, und Sie geben StatusCode, Body, RetryAfterMS plus einen der vier cts-Statuswerte zurück, während der Key das HSM nie verlässt
Der Provider klassifiziert Statuscodes selbst, eine Transportschicht, die eine beantwortete 503 in einen Permanent Failure verwandelt, schaltet die Retry-Logik also still ab, während Proxys und TLS-Policy in Code bleiben, der Ihnen gehört

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

HotPDF-Diagramm des SAD-Lebenszyklus: Mit RequireSAD und AutoAuthorize lädt der Provider credentials/info einmal, fragt den Authentication-Callback nach OTP- oder PIN-Werten, postet credentials/authorize, pollt credentials/authorizeCheck bis zu 60-mal im 250-ms-Takt, wenn die Antwort 202 ist, und löscht die Signature Activation Data, sobald signatures/signHash akzeptiert ist, wobei eine bezogene SAD aufbewahrt wird, wenn das Netz vor der Annahme fiel
Eine SAD, die im Speicher herumliegt, ist eine Autorisierung, die darauf wartet, für das falsche Dokument ausgegeben zu werden, und eine über Optionen vorgegebene SAD wird nur für eine Einzel-Hash-Anfrage verwendet
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 ctsPermanentFailure beenden den Aufruf mit spsProviderError, und das error_description (oder error) des Dienstes landet in LastError
  • 408, 429, 5xx und ctsTemporaryFailure werden bis zu RetryLimit (Voreinstellung 2) wiederholt, mit Warten auf Retry-After oder RetryBaseDelayMS (100 ms), verdoppelt je Versuch, gekappt bei MaxRetryAfterMS (5.000 ms)
  • Warten läuft in 25-ms-Scheiben, die Cancel prüfen, ein Nutzer, der abbricht, sitzt also kein Fünf-Sekunden-Backoff durch
  • signHash wird nur wiederholt, solange EnableIdempotency an ist; schalten Sie es ab, ist ein Timeout nach der Absendung final, denn niemand kann sagen, ob der Hash schon signiert wurde
HotPDF-Diagramm der Retry-Policy: Jede wiederholbare Anfrage trägt einen deterministischen csc--Idempotency-Key, gehasht aus Operationsidentifikator und Phase, HTTP 401 erzwingt genau ein Token-Refresh, andere 4xx-Antworten enden mit spsProviderError, und 408, 429, 5xx oder ein temporärer Transportfehler werden bis zum RetryLimit von 2 wiederholt, während auf Retry-After oder exponentielles Backoff mit 5.000-ms-Deckel gewartet wird
Abgeschlossene Stapel werden nach Operationsidentifikator, Credential und Fingerprint gecacht, und im Async-Modus lässt die gespeicherte responseID einen wiederholten Aufruf das Polling fortsetzen, statt den Hash neu einzureichen

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