Technisch artikel

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

HotPDF ondertekent PDF-documenten met een privésleutel die een externe Cloud Signature Consortium (CSC)-service beheert via THPDFCSCSignatureProvider, een signature provider die de CSC-API aanstuurt — credential info, autorisatie, signatures/signHash en polling — terwijl uw Delphi-applicatie het HTTP-transport en het OAuth-access token levert. De sleutel verlaat de HSM van de service nooit

Dat wordt steeds vaker de enige manier om überhaupt een qualified signing key te bemachtigen. Trust service providers geven een CSC-endpoint en een OAuth-client uit, geen PFX-bestand of USB-token, dus er is niets om in een lokale certificaatopslag te laden zoals ondertekenen via de Windows-certificaatopslag met CNG en CAPI doet. De naïeve integratie faalt op voorspelbare manieren: een signHash-aanroep timed out en de retry ondertekent hetzelfde contract twee keer, of een batch van veertig facturen lost veertig eenmalige wachtwoorden af omdat elke hash apart werd geautoriseerd. Het grootste deel van wat de provider doet is zich wapenen tegen die twee faalvormen

Waarom laat HotPDF HTTP over aan uw applicatie?

Omdat het transport precies het punt is waar elke implementatie verschilt. Proxies, TLS pinning, clientcertificaten, corporate OAuth-kluizen en loggingpolicy wonen allemaal in de HTTP-laag, dus THPDFCSCSignatureProvider orkestreert de protocoltoestand en roept per request een THPDFCSCTransport-functie aan. De provider geeft u een THPDFCSCTransportRequest mee met Method (altijd POST), de volledige URL opgebouwd uit ServiceBaseURL plus het endpoint-pad, een kant-en-klare Authorization-bearerheader, ContentType, de JSON-Body, een IdempotencyKey, het Attempt-nummer en MaxResponseBytes. U vult een THPDFCSCTransportResponse met StatusCode, Body en RetryAfterMS, en geeft één van 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
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  // headernaam zoals uw service die 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);            // socket- of DNS-probleem: retryable
        end;
        Response.StatusCode := HttpResp.StatusCode;   // 503 doorgeven zoals hij is, niet classificeren
        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;

De enige regel die u uit uw hoofd moet leren: geef ctsSuccess terug zodra een server daadwerkelijk heeft geantwoord, ook met een 503. De provider classificeert statuscodes zelf, en een transport dat een 429 tot ctsPermanentFailure verklaart schakelt de hieronder beschreven retry-logica geruisloos uit. De constructor is streng in de andere richting — hij gooit een EHPDFCSCSignatureProviderError wanneer het transport ontbreekt, CredentialID leeg is, noch een AccessToken noch een token-callback wordt meegegeven, een budget buiten bereik valt, of ServiceBaseURL geen HTTPS is. Kale http:// wordt alleen geaccepteerd met AllowInsecureHTTP, wat in een testopstelling thuishoort en nergens anders

Wat is de SAD, en waarom gooit HotPDF haar na één gebruik weg?

THPDFCSCSignatureProvider behandelt de Signature Activation Data (SAD) als single-use: ze wordt gewist uit de providertoestand op het moment dat signatures/signHash wordt geaccepteerd, zelfs wanneer de handtekening zelf later binnenkomt via asynchrone polling. De SAD is het bewijs van de service dat de ondertekenaar precies deze hashes heeft goedgekeurd, en een SAD die in het geheugen blijft hangen is een autorisatie die wacht om uitgegeven te worden aan het verkeerde document

Met de defaults uit THPDFCSCOptions.Default — RequireSAD en AutoAuthorize allebei True — laadt de provider één keer credentials/info, vraagt uw THPDFCSCAuthenticationCallback om de authData-waarden (een OTP, een PIN, wat de auth-blok van de credential ook eist), en post credentials/authorize. Een 200 brengt de SAD rechtstreeks mee; een 202 brengt een handle mee die via credentials/authorizeCheck tot MaxPollAttempts (60) keer wordt gepolld met tussenpozen van PollIntervalMS (250 ms). De callback mag hoogstens 32 waarden teruggeven, elk met een niet-lege ID van hoogstens 256 bytes en een waarde van hoogstens 4.096 bytes. Zakt het netwerk weg voordat signHash is geaccepteerd, dan blijft een automatisch verkregen SAD bewaard zodat dezelfde batch kan worden herhaald zonder de ondertekenaar opnieuw te vragen

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
      // uw OAuth-client; ForceRefresh is True nadat de service 401 antwoordde
      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   // uw UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

Een SAD die u zelf via Options.SAD meegeeft gedraagt zich anders, en bewust zo. HotPDF kan niet weten waarvoor hij is uitgegeven, dus de provider gebruikt een voorgezette SAD alleen voor een single-hash-request. Bij een batch met AutoAuthorize uit faalt de provider met "CSC SAD is not pinned to the requested hash batch" in plaats van te gokken

Hoe ondertekent SignHashBatch veel documenten met één autorisatie?

SignHashBatch stuurt één credentials/authorize en één signatures/signHash voor maximaal MaxBatchSignatures (64) digests, en bouwt beide bodies uit dezelfde array zodat numSignatures, de volgorde van hashes en hashAlgorithmOID in de twee aanroepen identiek zijn. Die match is wat het CSC-multisignmodel vereist. Loop de single-hash-Sign-methode veertig keer af en u krijgt veertig autorisaties; stuur een authorize en een signHash die niet bij elkaar passen en de service kan de SAD verbruiken tegen de verkeerde batch

Vóór elke netwerkactiviteit valideert de provider de batch. Elke request moet een digest (sikDigest) zijn van 1 tot 1.024 bytes met een digest-OID, en alle requests moeten één signature-algoritme-OID, één digest-OID en, voor RSASSA-PSS, één saltlengte delen. Een multi-hash-batch laadt bovendien credentials/info en geeft spsUnsupported terug wanneer de multisign-waarde van de credential kleiner is dan de batch. De SAD wordt daarna vastgepind aan een batchvingerafdruk — een SHA-256 over een versielabel, het aantal en per request de algoritme-OID, digest-OID, het algoritme, de saltlengte en de digestbytes, elk met lengtevoorvoegsel. Wissel twee hashes om en het is een andere batch die een verse autorisatie nodig heeft

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: SHA-256-waarden die u zelf berekende
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo wordt 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;
  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] hoort bij Digests[I]; het aantal is tegen het request gecontroleerd
end;

Voor RSASSA-PSS stuurt de provider ook signAlgoParams mee, een base64 DER-RSASSA-PSS-params-structuur met hash-algoritme, MGF1 en saltlengte. Die bouwen betekent OIDs encoderen, en v2.748.5 fixte daarvan een hoekje: X.690 §8.19.4 vouwt de eerste twee arcen samen tot één waarde (40 × eerste + tweede), en onder de 2-wortel duwt een tweede boog boven 39 die waarde voorbij 127, waar hij de base-128-multibyte-vorm nodig heeft die eerdere builds niet toepasten. Geen enkele SHA-2-OID wordt geraakt — 2.16 vouwt naar 96 — maar een misvormde OID gooit nu de eigen fout van de provider in plaats van een EConvertError

Waarom levert een herhaald request geen tweede handtekening op?

THPDFCSCSignatureProvider laat elke retryable aanroep een deterministische idempotency key meedragen en cachet voltooide resultaten, dus een retry na een verloren antwoord geeft de originele handtekeningen terug in plaats van de HSM om nieuwe te vragen. De key is csc- gevolgd door de hexadecimale SHA-256 van de operation identifier en de fase, en de fase embedt de batchvingerafdruk voor zowel autorisatie als signHash. Hashen in plaats van afkappen telt: twee lange operation-ID's die een prefix delen zouden onder afkapping colliden, terwijl een key met vaste lengte en op inhoud gebaseerd uniek en stabiel blijft over pogingen heen

Het retrybeleid in het gedeelde request-pad is met opzet smal:

  • HTTP 401 dwingt precies één token-verversing af via de access-token-callback, daarna wordt het request één keer herhaald wanneer er een access-token-callback is toegewezen; een tweede 401 is definitief
  • Andere 4xx-antwoorden en ctsPermanentFailure beëindigen de aanroep met spsProviderError, en de error_description van de service belandt in LastError
  • 408, 429, 5xx en ctsTemporaryFailure worden herhaald tot RetryLimit (default 2), met wachten op Retry-After of RetryBaseDelayMS × 2attempt (100 ms basis), begrensd 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 aanstaat; zet hem uit en een timeout na inzending is definitief, want niemand kan zeggen of de key al dan niet is gebruikt
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) voegt nog één wachtpost toe: de responseID wordt bewaard vóór het pollen van signatures/signPolling, dus een herhaalde aanroep met dezelfde operation identifier hervat de polling in plaats van opnieuw in te zenden. Voltooide batches zitten in een cache met operation identifier, credential en vingerafdruk als sleutel, gecapteerd door MaxOperationCacheEntries (128) en teruggegeven als deep copies. Die cache woont in de providerinstantie en overleeft geen herstart. De idempotency key wel, want hij wordt afgeleid in plaats van random gekozen, dus een herstart proces dat zijn operation identifier hergebruikt stuurt dezelfde key — of de service daarop dedupliceert is een belofte van de service, niet van HotPDF

Hoe krijgt een CSC-handtekening een plek in een PDF?

Geef de provider door 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 als dat in HotPDF pluggable signature providers voor ML-DSA en EdDSA wordt behandeld

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;

Het afmeten van de /Contents-placeholder loopt via EstimateSignatureSize, die EstimatedSignatureBytes teruggeeft wanneer u die zet en anders de RSA-modulusgrootte uit de sleutellengte van de credential. Voor ECDSA zet u EstimatedSignatureBytes zelf, anders meldt de schatting spsUnsupported. De auto-size-ondertekenvariant tekent opnieuw wanneer een placeholder te klein blijkt, en doet dat alleen voor providers die spcSafeSignRetry adverteren — wat THPDFCSCSignatureProvider alleen doet zolang EnableIdempotency aanstaat. Voor PAdES-B-T-workflows vraagt TimestampDigest een timestamp-token bij dezelfde service via signatures/timestamp, begrensd op MaxTimestampBytes (1 MB)

Wat doet de CSC-provider niet?

Hij ondertekent geen berichten, alleen digests. Ed25519 en Ed448 in pure mode geven de provider het hele signed-attributes-bericht (sikMessage) mee, en de batchvalidator verwerpt dat als misvormd, want signHash is per definitie hash-gebaseerd. De provider-unit compileert onder Free Pascal met gewone functietypes in plaats van anonieme methodes, maar de provider-gestuurde CMS-builders gooien vandaag onder FPC, dus een CSC-handtekening in een PDF embedden is een Delphi-pad

Hij beslist ook geen policy. CredentialInfo meldt de keystatus, de certificaatstatus, de autorisatiemodus, het SCAL-niveau en de multisign-limiet, maar de provider weigert niet uit eigen beweging een uitgeschakelde key of een SCAL1-credential — controleer dat voordat u een ondertekenaar de OTP-prompt toont. En één providerinstantie ondertekent één batch tegelijk: SignHashBatch wordt intern geserialiseerd zodat twee threads niet om één SAD kunnen strijden, wat betekent dat doorvoer uit batchen komt, niet uit het delen van een provider over workerthreads. Of de resulterende handtekening qualified is hangt af van de trust service en zijn credential, niet van de library die de hash erheen heeft gedragen

De CSC-provider, de CMS- en PAdES-builders en de lokale en PKCS#11-providers zitten allemaal in de HotPDF Delphi PDF component