Technisch artikel

HotPDF CSC: cloud-PDF-handtekeningen op afstand in Delphi

Een signatures/signHash-aanvraag naar een remote signing-dienst kan time-outen nadat de HSM de handtekening al heeft geproduceerd, en de client kan niet zien aan welke kant van die lijn hij is beland. Blind herhalen en één goedkeuring kan twee handtekeningen op hetzelfde contract opleveren. Weigeren te herhalen en een nachtelijke batch sterft op één 503. Veertig facturen ondertekenen, één hash per keer, en de ondertekenaar tikt veertig eenmalige wachtwoorden in

Elke Cloud Signature Consortium-integratie (CSC) moet allebei oplossen. Trust service providers die gekwalificeerde sleutels uitgeven, geven steeds vaker een CSC-API-eindpunt en een OAuth-client uit in plaats van een PFX-bestand of een USB-token, dus er is niets om in een lokale store te importeren zoals Windows cert store-ondertekening via CNG en CAPI werkt. De sleutel blijft in de HSM van de provider, en elke handtekening is een retour over HTTPS

Waarom laat HotPDF HTTP over aan uw applicatie?

HotPDF ondertekent via een externe CSC-dienst met THPDFCSCSignatureProvider, een signature provider die het CSC v2-protocol draait (credential info, autorisatie, signatures/signHash, polling) terwijl je Delphi-code het HTTP-transport en de OAuth-token bezit. Proxies, TLS pinning, clientcertificaten, token vaults en request-logging wonen allemaal in de HTTP-laag, en ze verschillen per deployment

Voor elke aanvraag bouwt de provider een THPDFCSCTransportRequest en roept hij je functie THPDFCSCTransport aan. Het record draagt de volledige URL, een kant-en-klare Authorization-bearerheader, de JSON-Body, een IdempotencyKey, de OperationIdentifier en het Attempt-nummer. Jouw functie vult een THPDFCSCTransportResponse met StatusCode, Body en RetryAfterMS, en geeft dan ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure of ctsCancelled terug

Diagram van de CSC-transportgrens in HotPDF: THPDFCSCSignatureProvider orkestreert het protocol en geeft uw code een THPDFCSCTransportRequest mee met een POST-method, de volledige URL, een kant-en-klare Authorization-bearerheader, de JSON-body, een IdempotencyKey en het attempt-nummer, en u geeft StatusCode, Body, RetryAfterMS plus één van de vier cts-statuswaarden terug terwijl de sleutel de HSM nooit verlaat
De provider classificeert statuscodes zelf, dus een transport dat een geantwoorde 503 tot permanent falen verklaart schakelt de retry-logica geruisloos uit, terwijl proxies en TLS-policy in code blijven die van u is

Een transport gebouwd op de RTL-THTTPClient heeft zo'n veertig regels nodig. De body gaat eruit als ruwe bytes, dus de JSON passeert nooit een codepaginaconversie:

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   // headernaam zoals jouw dienst hem documenteert
          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- of socketperikelen: herhaalbaar
        end;
        Response.StatusCode := HttpResp.StatusCode; // geef 429 en 503 ongewijzigd door
        SetLength(Response.Body, Reply.Size);
        if Reply.Size > 0 then
          Move(Reply.Memory^, Response.Body[1], Reply.Size);
        // Retry-After in seconden; een HTTP-date-waarde valt hier terug op 0
        Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
        Result := ctsSuccess;
      finally
        Reply.Free;
        Body.Free;
      end;
    end;
end;

De enige regel om te onthouden: geef ctsSuccess terug telkens als een server geantwoord heeft, ook met een 503. De provider classificeert statuscodes zelf, en een transport dat een 429 naar ctsPermanentFailure mapt zet de herhalingslogica hieronder uit. Bodys groter dan MaxResponseBytes (2 MB default) worden geweigerd met spsMalformed wat het transport ook teruggeeft

De constructor is de andere kant op streng. Hij gooit een EHPDFCSCSignatureProviderError als het transport ontbreekt, ServiceBaseURL of CredentialID leeg is, er geen AccessToken en geen token-callback is meegegeven, een budget buiten bereik ligt, of de URL geen HTTPS is. Kale http:// gaat alleen door met AllowInsecureHTTP gezet, en die vlag hoort thuis in een testrig gericht op een lokale mock-dienst

Wat is de SAD, en waarom is hij eenmalig?

THPDFCSCSignatureProvider behandelt de Signature Activation Data (SAD) als eenmalig: hij wordt gewist uit de providertoestand op het moment dat signatures/signHash wordt geaccepteerd, ook als de handtekening zelf later via asynchrone polling binnenkomt. De SAD is het bewijs van de dienst dat de ondertekenaar precies deze hashes heeft goedgekeurd. Eén die in het geheugen blijft hangen is een autorisatie die wacht om besteed te worden aan het verkeerde document

Met de defaults uit THPDFCSCOptions.Default (RequireSAD en AutoAuthorize beide True) laadt de provider één keer credentials/info, vraagt hij je THPDFCSCAuthenticationCallback om de authData-waarden die het auth-blok van de credential eist, meestal een OTP of een PIN, en post hij credentials/authorize. Een antwoord 200 draagt de SAD rechtstreeks. Een 202 draagt een handle, die de provider polst via credentials/authorizeCheck tot MaxPollAttempts (60) keer op PollIntervalMS (250 ms), tenzij de dienst Retry-After stuurt. De callback mag ten hoogste 32 waarden geven, elk met een niet-lege ID van tot 256 bytes en een waarde van tot 4.096 bytes

Het wissen gebeurt na acceptatie. Valt het netwerk weg voordat signHash is geaccepteerd, dan blijft een automatisch verkregen SAD op zijn plek, nog steeds gekoppeld aan dezelfde batch, dus de herhaling gaat eruit zonder een tweede OTP-prompt. De ondertekenaar keurde één keer goed; de provider besteedt die goedkeuring één keer

Diagram van de SAD-levenscyclus in HotPDF: met RequireSAD en AutoAuthorize laadt de provider één keer credentials/info, vraagt de authenticatie-callback om OTP- of PIN-waarden, post credentials/authorize, polld credentials/authorizeCheck tot 60 keer met 250 ms tussenpozen wanneer het antwoord 202 is, en wist de Signature Activation Data op het moment dat signatures/signHash wordt geaccepteerd, met behoud van een verkregen SAD wanneer het netwerk vóór acceptatie wegviel
Een SAD die in het geheugen blijft hangen is een autorisatie die wacht om uitgegeven te worden aan het verkeerde document, en een voorgezette SAD die via options binnenkomt wordt alleen voor een single-hash-request gebruikt
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
      // TokenVault is je eigen OAuth-client; ForceRefresh is True na een 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   // je eigen UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

De token-callback gaat af als de gecachte token ontbreekt of binnen TokenRefreshSkewSeconds (30) van de expiratie ligt die je meldde; een ExpiresAtUTC van nul betekent dat de token wordt hergebruikt tot een 401 een verversing afdwingt

Een SAD die je zelf via Options.SAD doorgeeft gedraagt zich anders, en met opzet. Niets vertelt de provider welke hashes die SAD dekt, dus een voorgezette SAD wordt alleen gebruikt voor een single-hash-aanvraag. Voor een multi-hash-batch met AutoAuthorize aan gooit hij de voorgezette waarde weg en autoriseert de batch; met AutoAuthorize uit faalt hij met "CSC SAD is not pinned to the requested hash batch" in plaats van te gokken. Diensten die uitsluitend via de OAuth-scope autoriseren en nooit een SAD uitgeven, werken met RequireSAD op False, in welk geval het SAD-lid uit de signHash-body wordt weggelaten

Veertig facturen ondertekenen met één OTP-prompt

SignHashBatch stuurt één credentials/authorize en één signatures/signHash voor tot MaxBatchSignatures (64) digests, en bouwt beide bodies uit dezelfde array, dus numSignatures, de volgorde van hashes en hashAlgorithmOID stemmen tussen de twee aanroepen overeen. Loop de single-hash-Sign-methode veertig keer en je krijgt veertig autorisaties. Stuur een authorize en een signHash die niet overeenstemmen en de dienst kan de SAD besteden aan de verkeerde batch

Validatie draait voordat er ook maar één byte netwerkverkeer gaat. Elke aanvraag moet een digest zijn (sikDigest) van 1 tot 1.024 bytes met een digest-OID, en alle aanvragen moeten één handtekeningalgoritme, één digest-OID en, voor RSASSA-PSS, één zoutlengte delen. Een multi-hash-batch laadt ook credentials/info en geeft spsUnsupported terug wanneer de multisign-waarde van de credential kleiner is dan de batchgrootte. De autorisatie wordt dan gebonden aan een volgordegevoelige SHA-256-vingerafdruk van de aanvragenlijst, dus twee hashes omwisselen maakt een andere batch die een verse goedkeuring nodig heeft

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  OpId: AnsiString;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: SHA-256-waarden die je zelf berekende
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;   // signAlgo afgeleid wanneer AlgorithmOID leeg is
    Requests[I].DigestAlgorithmOID := '2.16.840.1.101.3.4.2.1';
    Requests[I].InputKind := sikDigest;
    Requests[I].Input := Digests[I];
  end;

  // Leid de ID af uit je eigen runrecord, nooit uit een verse GUID per aanroep
  OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
  Status := Provider.SignHashBatch(Requests, OpId, Signatures);
  if Status = spsProviderError then
    // Zelfde OpId: een voltooide batch komt uit de cache, een wachtende
    // async-job hervat de polling, een niet-verzonden aanvraag hergebruikt dezelfde sleutel
    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] hoort bij Digests[I]; het aantal was tegen de aanvraag gecontroleerd
end;

Voor RSASSA-PSS voegt de provider signAlgoParams toe, een base64 DER RSASSA-PSS-params-structuur met het hashalgoritme, MGF1 en de zoutlengte. X.690 §8.19.4 vouwt de eerste twee OID-arcen in één waarde (40 × eerste + tweede), en onder de wortel 2 duwt een tweede arc boven 39 hem voorbij 127, waar hij de base-128 multi-byte-vorm nodig heeft. Geen enkele SHA-2-OID oefent dat pad, want 2.16 vouwt naar 96, dus een encoder kan elke SHA-2-test halen en toch fout zitten. De provider schrijft de multi-byte-vorm en meldt een misvormde OID als zijn eigen EHPDFCSCSignatureProviderError in plaats van een EConvertError

Waarom levert een herhaald request geen tweede handtekening op?

THPDFCSCSignatureProvider geeft elke herhaalsbare aanroep een deterministische idempotency key en cachet voltooide resultaten, dus een herhaling na een verloren antwoord geeft de originele handtekeningen terug in plaats van de HSM om nieuwe te vragen. De sleutel is csc- gevolgd door de hex SHA-256 van de operation identifier en de protocolfase, en voor autorisatie en signHash bevat de fase de batchvingerafdruk. Hashen in plaats van afkappen houdt de sleutel op vaste lengte en voorkomt dat twee lange operation ID's met een gedeelde prefix botsen

Het herhalingsbeleid in de gedeelde aanvraagroute is smal:

  • HTTP 401 dwingt exact één tokenverversing af via de access-token-callback, en de aanvraag wordt één keer herhaald; een tweede 401 is definitief, en zonder token-callback is de eerste dat al
  • Andere 4xx-antwoorden en ctsPermanentFailure beëindigen de aanroep met spsProviderError, en de error_description (of error) van de dienst belandt in LastError
  • 408, 429, 5xx en ctsTemporaryFailure worden herhaald tot RetryLimit (default 2), met wachten op Retry-After of RetryBaseDelayMS (100 ms) verdubbeld per poging, afgekapt op MaxRetryAfterMS (5.000 ms)
  • Wachttijden lopen in slices van 25 ms die Cancel controleren, dus een gebruiker die afbreekt hoeft geen back-off van vijf seconden uit te zitten
  • signHash wordt alleen herhaald zolang EnableIdempotency aan staat; zet hem uit en een timeout na verzending is definitief, want niemand kan zien of de hash al was ondertekend
Diagram van het retrybeleid in HotPDF: elke retryable aanroep draagt een deterministische csc- idempotency key gehasht uit operation identifier en fase, HTTP 401 dwingt precies één token-verversing af, andere 4xx-antwoorden eindigen met spsProviderError, en 408, 429, 5xx of een tijdelijk transportfalen worden herhaald tot RetryLimit van 2 met wachten op Retry-After of exponentiële backoff begrensd op 5.000 ms
Voltooide batches worden gecached op operation identifier, credential en vingerafdruk, en in asynchrone modus laat de bewaarde responseID een herhaalde aanroep de polling hervatten in plaats van de hash opnieuw in te zenden

Asynchroon ondertekenen (operationMode "A", de default, met een validity_period van AsyncValidityPeriodMS, 60.000 ms) voegt nog een vangrail toe. De responseID wordt bewaard voordat de provider begint te polsten bij signatures/signPolling, dus een herhaalde aanroep met dezelfde operation identifier pakt de wachtende job op in plaats van de hash opnieuw in te zenden. Voltooide batches worden per providerinstantie gecacht, tot MaxOperationCacheEntries (128), en die cache verdwijnt na een herstart. De idempotency key overleeft het, want hij wordt afgeleid in plaats van willekeurig: een herstart proces dat zijn operation identifier hergebruikt stuurt dezelfde sleutel, en of de dienst daarop dedupliceert hoort bij het contract van de dienst

Dit alles hangt aan de operation identifier, en de parameter is makkelijk leeg te laten. Geef '' aan SignHashBatch en de provider valt terug op de OperationIdentifier op de aanvragen, en daarna op een nieuwe GUID. Een nieuwe GUID per aanroep betekent dat je eigen herhalingslus elke keer een nieuwe operatie start: nieuwe cachesleuf, nieuwe idempotency key, en misschien een tweede OTP-prompt. Het betekent ook dat Provider.Cancel niets heeft om op te mikken, want je code zag de GUID nooit. Leid de identifier af uit iets dat je toch al bewaart, zoals het batchnummer of het document-ID, en laat EnableIdempotency op zijn default True. Wijst de dienst de header af, laat hem dan in het transport vallen in plaats van de optie uit te zetten

Een CSC-handtekening in een PDF inbedden

Geef de provider mee aan HPDFCMSSignPDFStreamWithProvider samen met het end-entity-certificaat uit GetCertificateChain; HotPDF bouwt de CMS SignedData en de provider ondertekent de digest van de signed attributes. De invoer-PDF heeft de /ByteRange- en /Contents-placeholder nodig die THPDFPage.AddSignedSignatureField wegschrijft, precies zoals in de PAdES-ondertekenworkflow in HotPDF, en het providermodel is hetzelfde model achter de pluggable signature providers van HotPDF voor ML-DSA en EdDSA

var
  Chain: THPDFCSCCertificateChain;
  SignOpts: THPDFCMSSignOptions;
  Src, Dst: TFileStream;
begin
  if Provider.RefreshCredentialInfo <> spsValid then
    raise Exception.Create(Provider.LastError);
  Chain := Provider.GetCertificateChain;   // CSC zet het end-entity-certificaat vooraan
  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;

Voor placeholdergrootte geeft EstimateSignatureSize EstimatedSignatureBytes terug als je die zet, anders de RSA-modulusgrootte afgeleid uit de sleutellengte in credentials/info. Voor ECDSA-sleutels antwoordt hij spsUnsupported en valt de placeholder-schatter terug op het certificaat, dus EstimatedSignatureBytes zelf zetten geeft een voorspelbaar resultaat. HPDFCMSSignPDFStreamWithProviderAutoSize bouwt opnieuw en ondertekent opnieuw als een placeholder te klein blijkt, en dat doet hij alleen voor providers die spcSafeSignRetry adverteren. THPDFCSCSignatureProvider adverteert hem zolang EnableIdempotency aan staat, wat nog een reden is om die default met rust te laten

Voor PAdES-B-T vraagt TimestampDigest dezelfde dienst om een RFC 3161-token via signatures/timestamp, met een optionele nonce en een groottengrens van MaxTimestampBytes (1 MB)

Policybeslissingen die in jouw code blijven

De provider ondertekent digests. Ed25519 en Ed448 in pure mode geven een signature provider de hele signed-attributes-boodschap (sikMessage), en de batchvalidator wijst dat af als misvormd, want signHash is per definitie hash-gebaseerd. Onder Free Pascal compileert de providerunit met kale functietypes in plaats van anonieme methoden, dus SignHashBatch en TimestampDigest zijn daar aanroepbaar; het resultaat in een PDF inbedden via HPDFCMSSignPDFStreamWithProvider is een Delphi-pad

De provider laat ook policy aan jou. CredentialInfo meldt KeyStatus, CertificateStatus, AuthorizationMode, SCAL en MultiSignatures, maar een uitgezette sleutel of een SCAL1-credential is jouw oordeel om te weigeren. Controleer die velden direct na RefreshCredentialInfo en voordat de ondertekenaar ooit een OTP-prompt ziet; een weigering van de dienst nadat de ondertekenaar een code heeft getypt is een slechtere ervaring dan een heldere fout vooraf

Één providerinstantie draait één batch per keer, want SignHashBatch, RefreshCredentialInfo en TimestampDigest delen een lock zodat twee threads niet om één SAD kunnen racen. Doorvoer komt uit grotere batches, tot aan de multisign-limiet van de credential. Of de resulterende handtekening als gekwalificeerd telt, wordt beslist door de trust service en de credential die hij uitgaf

De CSC-provider verschepet naast de CMS- en PAdES-builders en de lokale en PKCS#11-signature providers in de HotPDF Delphi PDF-component, zodat ondertekenen op afstand, met token en vanuit bestand allemaal dezelfde PDF-ondertekenaanroepen voeden