Teknisk artikel

HotPDF CSC remote signing: cloud-PDF-signaturer i Delphi

HotPDF signerer PDF-dokumenter med en privat nøgle hos en ekstern Cloud Signature Consortium-tjeneste (CSC) gennem THPDFCSCSignatureProvider, en signature provider, der driver CSC-API'en — credential info, autorisation, signatures/signHash og polling — mens din Delphi-applikation leverer HTTP-transporten og OAuth access token. Nøglen forlader aldrig tjenestens HSM

Det er i stigende grad den eneste måde at få en kvalificeret signeringsnøgle overhovedet. Trust service providers udleverer et CSC-endpoint og en OAuth-klient, ikke en PFX-fil eller en USB-token, så der er intet at loade ind i et lokalt certifikatlager, sådan som Windows cert store-signering gennem CNG og CAPI gør. Den naive integration fejler på forudsigelige måder: et signHash-kald timer ud, og retry'en signerer samme kontrakt to gange, eller en batch på fyrre fakturaer udløser fyrre engangskoder, fordi hver hash blev autoriseret separat. Det meste af, hvad provideren gør, er at forsvare sig mod de to fejl

Hvorfor lader HotPDF HTTP være din applikations sag?

Fordi transporten er præcis dér, hvor hver deployment adskiller sig. Proxies, TLS pinning, client certificates, virksomhedens OAuth-vaults og logningspolicy bor alle i HTTP-laget, så THPDFCSCSignatureProvider orkestrerer protokoltilstanden og kalder en THPDFCSCTransport-funktion for hver request. Provideren giver dig en THPDFCSCTransportRequest med Method (altid POST), den fulde URL bygget af ServiceBaseURL plus endpoint-stien, en klar Authorization bearer-header, ContentType, JSON-Body, en IdempotencyKey, Attempt-nummeret og MaxResponseBytes. Du fylder en THPDFCSCTransportResponse med StatusCode, Body og RetryAfterMS og returnerer én af ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure eller ctsCancelled

HotPDF-diagram over CSC transport-grænse: THPDFCSCSignatureProvider orkestrerer protokollen og giver din kode en THPDFCSCTransportRequest med POST-metode, den fulde URL, en klar Authorization bearer-header, JSON-body, en IdempotencyKey og forsøgsnummeret, og du returnerer StatusCode, Body, RetryAfterMS plus én af de fire cts-statusværdier, mens nøglen aldrig forlader HSM'en
Provideren klassificerer selv statuskoder, så en transport, der gør en besvaret 503 til en permanent fejl, deaktiverer retry-logikken i stilhed, mens proxies og TLS-policy bliver i kode, du selv ejer
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  // headernavn som din tjeneste dokumenterer det
          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- eller DNS-problemer: kan retries
        end;
        Response.StatusCode := HttpResp.StatusCode;   // rapportér 503 som den er, klassificér ikke
        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;

Den ene regel, der er værd at huske: returnér ctsSuccess, når som helst en server faktisk svarede, også med en 503. Provideren klassificerer selv statuskoder, og en transport, der gør en 429 til ctsPermanentFailure, deaktiverer i stilhed den retry-logik, der beskrives nedenfor. Constructoren er streng i den anden retning — den rejser EHPDFCSCSignatureProviderError, når transporten mangler, CredentialID er tom, hverken en AccessToken eller en token-callback er givet, en budget er ude af interval, eller ServiceBaseURL ikke er HTTPS. Bar http:// accepteres kun med AllowInsecureHTTP, som hører hjemme i et test-setup og ingen andre steder

Hvad er SAD, og hvorfor smider HotPDF den væk efter én brug?

THPDFCSCSignatureProvider behandler Signature Activation Data (SAD) som single-use: den ryddes fra provider-tilstanden i det øjeblik signatures/signHash accepteres, selv når signaturen selv ankommer senere gennem asynkron polling. SAD'en er tjenestens bevis på, at underskriveren godkendte netop disse hashes, og en SAD, der ligger og driver i hukommelsen, er en autorisation, der venter på at blive brugt på det forkerte dokument

Med defaults fra THPDFCSCOptions.Default — RequireSAD og AutoAuthorize begge True — loader provideren credentials/info én gang, beder din THPDFCSCAuthenticationCallback om authData-værdierne (en OTP, en PIN, hvad end credentialens auth-blok kræver) og poster credentials/authorize. En 200 bærer SAD'en direkte; en 202 bærer et handle, der polles gennem credentials/authorizeCheck op til MaxPollAttempts (60) gange med PollIntervalMS (250 ms). Callbacken må returnere højst 32 værdier, hver med en ikke-tom ID på op til 256 bytes og en værdi på op til 4.096 bytes. Dropper netværket, før signHash accepteres, gemmes en automatisk opnået SAD, så samme batch kan retries uden at spørge underskriveren igen

HotPDF-diagram over SAD-livscyklus: med RequireSAD og AutoAuthorize loader provideren credentials/info én gang, beder authentication-callbacken om OTP- eller PIN-værdier, poster credentials/authorize, poller credentials/authorizeCheck op til 60 gange med 250 ms, når svaret er 202, og rydder Signature Activation Data i det øjeblik signatures/signHash accepteres, mens en opnået SAD gemmes, hvis netværket droppede før accepten
En SAD, der ligger og driver i hukommelsen, er en autorisation, der venter på at blive brugt på det forkerte dokument, og en forudindstillet SAD, der gives gennem options, bruges kun til en single-hash-request
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, async mode, 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
      // din OAuth-klient; ForceRefresh er True, efter tjenesten svarede 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   // din UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

En SAD, du giver selv gennem Options.SAD, opfører sig anderledes, og med vilje. HotPDF kan ikke vide, hvilke hashes den blev udstedt til, så provideren bruger en forudindstillet SAD kun til en single-hash-request. For en batch med AutoAuthorize slået fra fejler provideren med "CSC SAD is not pinned to the requested hash batch" i stedet for at gætte

Hvordan signerer SignHashBatch mange dokumenter med én autorisation?

SignHashBatch sender én credentials/authorize og én signatures/signHash for op til MaxBatchSignatures (64) digests og bygger begge bodier ud fra samme array, så numSignatures, rækkefølgen af hashes og hashAlgorithmOID er identiske i de to kald. Det match er, hvad CSC multisign-modellen kræver. Loop single-hash-Sign-metoden fyrre gange, og du får fyrre autorisationer; send en authorize og en signHash, der er uenige, og tjenesten kan bruge SAD'en på den forkerte batch

Før al netværkstrafik validerer provideren batchen. Hver request skal være en digest (sikDigest) på 1 til 1.024 bytes med en digest-OID, og alle requests skal dele én signaturalgoritme-OID, én digest-OID og, for RSASSA-PSS, én salt-længde. En multi-hash-batch loader også credentials/info og returnerer spsUnsupported, når credentialens multisign-værdi er mindre end batchen. SAD'en fastgøres derefter til et batch-fingeraftryk — en SHA-256 over et versionslabel, antallet og pr. request algoritme-OID, digest-OID, algoritme, salt-længde og digest-bytes, hver med længdepræfiks. Bytter du to hashes, er det en anden batch, der kræver en frisk autorisation

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: SHA-256-værdier, du har beregnet
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo afledt, når AlgorithmOID er tom
    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] hører til Digests[I]; antallet blev tjekket mod requesten
end;

Til RSASSA-PSS sender provideren også signAlgoParams, en base64 DER RSASSA-PSS-params-struktur med hash-algoritmen, MGF1 og salt-længden. At bygge den betyder at encode OID'er, og version 2.748.5 rettede et hjørne af det: X.690 §8.19.4 folder de første to arcs sammen til én værdi (40 × første + anden), og under 2-roden skubber en anden arc over 39 den værdi forbi 127, hvor den behøver base-128 multi-byte-formen, som tidligere builds ikke anvendte. Ingen SHA-2-OID er påvirket — 2.16 folder til 96 — men en misdannet OID rejser nu providerens egen fejl i stedet for en EConvertError

Hvorfor producerer en gentaget request ikke en ekstra signatur?

THPDFCSCSignatureProvider giver hvert retrybart kald en deterministisk idempotency-nøgle og cacher fuldførte resultater, så en retry efter et tabt svar returnerer de originale signaturer i stedet for at bede HSM'en om nye. Nøglen er csc- efterfulgt af hex SHA-256 af operations-identifikatoren og fasen, og fasen indlejrer batch-fingeraftrykket for både autorisation og signHash. At hashe i stedet for at truncate betyder noget: to lange operations-ID'er, der deler et præfiks, ville kollidere under truncation, mens en fastlængde content-adresseret nøgle forbliver unik og stabil på tværs af forsøg

Retry-policen i den delte request-sti er bevidst snæver:

  • HTTP 401 gennemtvinger præcis én token refresh gennem access-token-callbacken, og derefter gentages requesten én gang, når en access-token-callback er tildelt; en anden 401 er endelig
  • Andre 4xx-svar og ctsPermanentFailure afslutter kaldet med spsProviderError, og tjenestens error_description lander i LastError
  • 408, 429, 5xx og ctsTemporaryFailure retries op til RetryLimit (default 2) med ventetid på Retry-After eller RetryBaseDelayMS × 2attempt (100 ms base), begrænset til MaxRetryAfterMS (5.000 ms)
  • Ventetider kører i 25 ms-skiver, der tjekker Cancel, så en bruger, der afbryder, ikke skal sidde ud en fem sekunders back-off
  • signHash retries kun, mens EnableIdempotency er slået til; slå den fra, og en timeout efter afsendelse er endelig, for ingen kan se, om nøglen allerede var brugt
HotPDF-diagram over retry-policy: hvert retrybart kald bærer en deterministisk csc- idempotency-nøgle hasheret af operations-identifikatoren og fasen, HTTP 401 gennemtvinger præcis én token refresh, andre 4xx-svar ender med spsProviderError, og 408, 429, 5xx eller en midlertidig transportfejl retries op til RetryLimit på 2 med ventetid på Retry-After eller eksponentiel backoff begrænset til 5.000 ms
Fuldførte batches caches efter operations-identifikator, credential og fingeraftryk, og i asynkron mode lader det gemte responseID et gentaget kald genoptage pollingen i stedet for at indsende hashen igen

Asynkron signering (operationMode "A", default) tilføjer endnu en vagt: responseID gemmes, før der polles signatures/signPolling, så et gentaget kald med samme operations-identifikator genoptager pollingen i stedet for at indsende igen. Fuldførte batches ligger i en cache nøglet efter operations-identifikator, credential og fingeraftryk, begrænset af MaxOperationCacheEntries (128) og returneret som deep copies. Cachen lever i provider-instansen og overlever ikke en genstart. Idempotency-nøglen gør, for den er afledt og ikke tilfældig, så en genstartet proces, der genbruger sin operations-identifikator, sender samme nøgle — hvorvidt tjenesten deduplikerer på den, er tjenestens løfte, ikke HotPDFs

Hvordan får du en CSC-signatur ind i en PDF?

Giv provideren til HPDFCMSSignPDFStreamWithProvider sammen med end-entity-certifikatet fra GetCertificateChain; HotPDF bygger CMS SignedData, og provideren signerer digesten af de signed attributes. Input-PDF'en behøver /ByteRange- og /Contents-pladsholderen, som THPDFPage.AddSignedSignatureField skriver, præcis som i PAdES-signeringsworkflowet i HotPDF, og provider-modellen er den samme, der dækkes i HotPDF pluggable signature providers til ML-DSA og EdDSA

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

Dimensionering af /Contents-pladsholderen går gennem EstimateSignatureSize, som returnerer EstimatedSignatureBytes, når du sætter den, og ellers RSA modulus-størrelsen fra credentialens nøglelængde. Til ECDSA sæt selv EstimatedSignatureBytes, eller estimeringen rapporterer spsUnsupported. Auto-size signeringsvarianten signerer igen, når en pladsholder viser sig at være for lille, og den gør det kun for providers, der annoncerer spcSafeSignRetry — hvilket THPDFCSCSignatureProvider kun gør, mens EnableIdempotency er slået til. Til PAdES-B-T-workflows anmoder TimestampDigest om et timestamp-token fra samme tjeneste gennem signatures/timestamp, begrænset til MaxTimestampBytes (1 MB)

Hvad gør CSC-provideren ikke?

Den signerer ikke beskeder, kun digests. Ed25519 og Ed448 i pure mode giver provideren hele signed-attributes-beskeden (sikMessage), og batch-validatoren afviser det som misdannet, for signHash er per definition hash-baseret. Provider-uniten kompilerer under Free Pascal med almindelige funktionstyper i stedet for anonymous methods, men CMS-builderne drevet af provideren rejser under FPC i dag, så at embedde en CSC-signatur i en PDF er en Delphi-sti

Den beslutter heller ikke policy. CredentialInfo rapporterer nøglestatus, certifikatstatus, autorisationsmode, SCAL-niveau og multisign-grænse, men provideren vil ikke nægte en deaktiveret nøgle eller en SCAL1-credential af sig selv — tjek det, før du viser en underskriver OTP-prompten. Og én provider-instans signerer én batch ad gangen: SignHashBatch serialiseres internt, så to tråde ikke kan løbe om én SAD, hvilket betyder, at throughput kommer fra batchning, ikke fra at dele en provider på tværs af worker-tråde. Hvorvidt den resulterende signatur er kvalificeret afhænger af trust-tjenesten og dens credential, ikke af biblioteket, der bar hashen derud

CSC-provideren, CMS- og PAdES-builderne og de lokale og PKCS#11-providers følger alle med i HotPDF Delphi PDF-komponenten