Teknisk artikkel

HotPDF CSC fjernsignering: sky-PDF-signaturer i Delphi

HotPDF signerer PDF-dokumenter med en privatnøkkel holdt av en ekstern Cloud Signature Consortium-tjeneste (CSC) gjennom THPDFCSCSignatureProvider, en signaturprovider som driver CSC-API-et — credential info, autorisasjon, signatures/signHash og polling — mens Delphi-applikasjonen din leverer HTTP-transporten og OAuth access token. Nøkkelen forlater aldri tjenestens HSM

Det er i økende grad den eneste måten å få en kvalifisert signeringsnøkkel i det hele tatt. Tillitstjenesteleverandører deler ut et CSC-endepunkt og en OAuth-klient, ikke en PFX-fil eller en USB-token, så det er ingenting å laste inn i et lokalt sertifikatlager slik Windows cert store-signering gjennom CNG og CAPI gjør. Den naive integrasjonen feiler på forutsigbare måter: et signHash-kall får tidsavbrudd og retryen signerer samme kontrakt to ganger, eller en bunke på førti fakturaer utløser førti engangskoder fordi hver hash ble autorisert separat. Det meste provideren gjør, er å forsvare seg mot de to feilene

Hvorfor lar HotPDF HTTP ligge til applikasjonen din?

Fordi transporten er nøyaktig der hver utrulling er forskjellig. Proxyer, TLS pinning, klientsertifikater, bedriftens OAuth-hvelv og loggepolicy bor alle i HTTP-laget, så THPDFCSCSignatureProvider orkestrerer protokolltilstanden og kaller en THPDFCSCTransport-funksjon for hver forespørsel. Provideren gir deg en THPDFCSCTransportRequest med Method (alltid POST), hele URL-en bygget fra ServiceBaseURL pluss endepunktstien, en klar Authorization-bearer-header, ContentType, JSON-Body-en, en IdempotencyKey, Attempt-tallet og MaxResponseBytes. Du fyller en THPDFCSCTransportResponse med StatusCode, Body og RetryAfterMS, og returnerer én av ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure eller ctsCancelled

Diagram over HotPDF CSC transportgrense: THPDFCSCSignatureProvider orkestrerer protokollen og gir koden din en THPDFCSCTransportRequest med POST-metode, hele URL-en, en klar Authorization bearer-header, JSON-body-en, en IdempotencyKey og forsøkstallet, og du returnerer StatusCode, Body, RetryAfterMS pluss én av de fire cts-statusverdiene mens nøkkelen aldri forlater HSM-en
Provideren klassifiserer statuskoder selv, så en transport som gjør en besvart 503 om til en permanent feil, deaktiverer stille retry-logikken, mens proxyer og TLS-policy forblir i kode du eier
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 slik tjenesten din 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-problem: kan retries
        end;
        Response.StatusCode := HttpResp.StatusCode;   // rapporter 503 som den er, ikke klassifiser
        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 regelen verdt å memorere: returner ctsSuccess når som helst en server faktisk svarte, selv med en 503. Provideren klassifiserer statuskoder selv, og en transport som gjør en 429 om til ctsPermanentFailure, deaktiverer stille retry-logikken beskrevet nedenfor. Konstruktøren er streng i den andre retningen — den reiser EHPDFCSCSignatureProviderError når transporten mangler, CredentialID er tom, verken en AccessToken eller en token-callback er levert, et budsjett er utenfor området, eller ServiceBaseURL ikke er HTTPS. Ren http:// godtas bare med AllowInsecureHTTP, som hører hjemme i en testrigg og ingen andre steder

Hva er SAD-en, og hvorfor kaster HotPDF den etter én bruk?

THPDFCSCSignatureProvider behandler Signature Activation Data (SAD) som engangs: den tømmes fra providertilstanden i det øyeblikket signatures/signHash aksepteres, selv når signaturen selv ankommer senere gjennom asynkron polling. SAD-en er tjenestens bevis på at signeren godkjente nettopp disse hashene, og en SAD som blir hengende i minnet, er en autorisasjon som venter på å bli brukt opp på feil dokument

Med standardverdiene fra THPDFCSCOptions.Default — RequireSAD og AutoAuthorize begge True — laster provideren credentials/info én gang, spør THPDFCSCAuthenticationCallback din om authData-verdiene (en OTP, en PIN, hva enn credentialens auth-blokk krever), og poster credentials/authorize. En 200 bærer SAD-en direkte; en 202 bærer et handle som polles gjennom credentials/authorizeCheck opptil MaxPollAttempts (60) ganger med PollIntervalMS (250 ms). Callbacken kan returnere høyst 32 verdier, hver med en ikke-tom ID på opptil 256 byte og en verdi på opptil 4 096 byte. Hvis nettverket faller bort før signHash aksepteres, beholdes en automatisk innhentet SAD slik at samme bunke kan retries uten å spørre signeren igjen

Diagram over HotPDF SAD-livssyklus: med RequireSAD og AutoAuthorize laster provideren credentials/info én gang, spør autentiserings-callbacken om OTP- eller PIN-verdier, poster credentials/authorize, poller credentials/authorizeCheck opptil 60 ganger med 250 ms når svaret er 202, og tømmer Signature Activation Data i det øyeblikket signatures/signHash aksepteres, mens en innhentet SAD beholdes når nettverket faller bort før aksept
En SAD som blir hengende i minnet, er en autorisasjon som venter på å bli brukt på feil dokument, og en forhåndsatt SAD sendt gjennom options brukes bare for en enkelt-hash-forespørsel
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
      // din OAuth-klient; ForceRefresh er True etter at tjenesten svarte 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   // ditt UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

En SAD du sender inn selv gjennom Options.SAD, oppfører seg annerledes, og bevisst så. HotPDF kan ikke vite hvilke hasher den ble utstedt for, så provideren bruker en forhåndsatt SAD bare for en enkelt-hash-forespørsel. For en bunke med AutoAuthorize slått av, feiler provideren med «CSC SAD is not pinned to the requested hash batch» i stedet for å gjette

Hvordan signerer SignHashBatch mange dokumenter med én autorisasjon?

SignHashBatch sender én credentials/authorize og én signatures/signHash for opptil MaxBatchSignatures (64) digester, og bygger begge bodyene fra den samme arrayen slik at numSignatures, rekkefølgen på hashes og hashAlgorithmOID er identiske i de to kallene. Det treffet er det CSC-multisign-modellen krever. Løkk den enkelts-hash Sign-metoden førti ganger, så får du førti autorisasjoner; send en authorize og en signHash som er uenige, så kan tjenesten bruke SAD-en mot feil bunke

Før noe nettverkstrafikk validerer provideren bunker. Hver forespørsel må være en digest (sikDigest) på 1 til 1 024 byte med en digest-OID, og alle forespørsler må dele én signaturalgoritme-OID, én digest-OID og, for RSASSA-PSS, én saltlengde. En multi-hash-bunke laster også credentials/info og returnerer spsUnsupported når credentialens multisign-verdi er mindre enn bunker. SAD-en festes deretter til et bunkefingeravtrykk — en SHA-256 over en versjonsetikett, antallet og, per forespørsel, algoritme-OID, digest-OID, algoritme, saltlengde og digest-byte, hver lengdeprefikset. Bytt to hasher, så er det en annen bunke som trenger en fersk autorisasjon

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: SHA-256-verdiene du beregnet
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo utledes 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] tilhører Digests[I]; antallet ble sjekket mot forespørselen
end;

For RSASSA-PSS sender provideren også signAlgoParams, en base64 DER RSASSA-PSS-params-struktur med hashalgoritmen, MGF1 og saltlengden. Å bygge den betyr å enkode OID-er, og versjon 2.748.5 fikset en krok av det: X.690 §8.19.4 folder de to første buene inn i én verdi (40 × første + andre), og under 2-roten dytter en andre bue over 39 den verdien forbi 127, der den trenger base-128-flerbyteformen som tidligere bygg ikke anvendte. Ingen SHA-2-OID er berørt — 2.16 folder til 96 — men en misdannet OID reiser nå providerens egen feil i stedet for en EConvertError

Hvorfor gir en retried forespørsel ikke en ny signatur?

THPDFCSCSignatureProvider får hvert retrybare kall til å bære en deterministisk idempotensnøkkel og cachar ferdige resultater, så en retry etter et tapt svar returnerer de opprinnelige signaturene i stedet for å be HSM-en om nye. Nøkkelen er csc- fulgt av den heksadesimale SHA-256-en av operasjonsidentifikatoren og fasen, og fasen inneblander bunkefingeravtrykket for både autorisasjon og signHash. Å hashe i stedet for å trunkere betyr noe: to lange operasjons-ID-er som deler et prefiks, ville kollidere under trunkering, mens en fastlengde innholdsadressert nøkkel forblir unik og stabil på tvers av forsøk

Retry-policyen i den delte forespørselsstien er smal med hensikt:

  • HTTP 401 tvinger frem nøyaktig én token-oppfriskning gjennom access token-callbacken, så gjentas forespørselen én gang når en access token-callback er tildelt; en andre 401 er endelig
  • Andre 4xx-svar og ctsPermanentFailure avslutter kallet med spsProviderError, og tjenestens error_description lander i LastError
  • 408, 429, 5xx og ctsTemporaryFailure retries opptil RetryLimit (standard 2) ganger, med venting på Retry-After eller RetryBaseDelayMS × 2attempt (100 ms base), taksert til MaxRetryAfterMS (5 000 ms)
  • Ventingene kjører i 25 ms-skiver som sjekker Cancel, så en bruker som avbryter, ikke sitter gjennom en fem sekunders back-off
  • signHash retries bare mens EnableIdempotency er på; slå den av, og et tidsavbrudd etter innsending er endelig, for ingen kan si om nøkkelen allerede var brukt
Diagram over HotPDF retry-policy: hvert retrybare kall bærer en deterministisk csc- idempotensnøkkel hasket fra operasjonsidentifikatoren og fasen, HTTP 401 tvinger frem nøyaktig én token-oppfriskning, andre 4xx-svar ender med spsProviderError, og 408, 429, 5xx eller en midlertidig transportfeil retries opptil RetryLimit på 2 mens det ventes på Retry-After eller eksponentiell backoff taksert til 5 000 ms
Ferdige bunker caches etter operasjonsidentifikator, credential og fingeravtrykk, og i asynkron modus lar den lagrede responseID-en et gjentatt kall gjenoppta polling i stedet for å sende hashen på nytt

Asynkron signering (operationMode «A», standarden) legger til enda en vakt: responseID-en lagres før polling av signatures/signPolling, så et gjentatt kall med samme operasjonsidentifikator gjenopptar polling i stedet for å sende på nytt. Ferdige bunker ligger i en cache nøklet etter operasjonsidentifikator, credential og fingeravtrykk, taksert av MaxOperationCacheEntries (128) og returnert som dype kopier. Den cachen lever i providerinstansen og overlever ikke en omstart. Idempotensnøkkelen gjør det, for den er utledet snarere enn tilfeldig, så en omstartet prosess som gjenbruker sin operasjonsidentifikator, sender samme nøkkel — hvorvidt tjenesten dedupliserer på den, er tjenestens løfte, ikke HotPDFs

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

Send provideren til HPDFCMSSignPDFStreamWithProvider sammen med endenhetssertifikatet fra GetCertificateChain; HotPDF bygger CMS SignedData, og provideren signerer digesten av de signerte attributtene. Input-PDF-en trenger /ByteRange- og /Contents-plassholderen som THPDFPage.AddSignedSignatureField skriver, nøyaktig som i PAdES-signeringsarbeidsflyten i HotPDF, og providermodellen er den samme som dekkes i HotPDF pluggbare signatureproviders for 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 endenhetssertifikatet 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;

Størrelsessetting av /Contents-plassholderen går gjennom EstimateSignatureSize, som returnerer EstimatedSignatureBytes når du setter den, og ellers RSA-modulstørrelsen fra credentialens nøkkellengde. For ECDSA setter du EstimatedSignatureBytes selv, ellers rapporterer estimeringen spsUnsupported. Varianten med automatisk størrelse signerer på nytt når en plassholder viser seg for liten, og den gjør det bare for providers som annonserer spcSafeSignRetry — noe THPDFCSCSignatureProvider gjør bare mens EnableIdempotency er på. For PAdES-B-T-arbeidsflyter ber TimestampDigest om et tidsstempel-token fra samme tjeneste gjennom signatures/timestamp, taksert til MaxTimestampBytes (1 MB)

Hva gjør ikke CSC-provideren?

Den signerer ikke meldinger, bare digester. Ed25519 og Ed448 i ren modus gir provideren hele den signerte attributtmeldingen (sikMessage), og bunker-validatoren avviser den som misdannet, for signHash er per definisjon hashbasert. Provider-uniten kompilerer under Free Pascal med rene funksjonstyper i stedet for anonyme metoder, men providerdrevne CMS-byggere reiser under FPC i dag, så å bygge inn en CSC-signatur i en PDF er en Delphi-sti

Den avgjør heller ikke policy. CredentialInfo rapporterer nøkkelstatus, sertifikatstatus, autorisasjonsmodus, SCAL-nivå og multisign-grense, men provideren nekter ikke av seg selv en deaktivert nøkkel eller en SCAL1-credential — sjekk de før du viser en signerer OTP-spørsmålet. Og én providerinstans signerer én bunke om gangen: SignHashBatch serialiseres internt slik at to tråder ikke kan kappløpe om én SAD, noe som betyr at gjennomstrømning kommer fra batching, ikke fra å dele en provider på tvers av arbeidertråder. Hvorvidt den resulterende signaturen er kvalifisert, avhenger av tillitstjenesten og dens credential, ikke av biblioteket som bar hashen dit

CSC-provideren, CMS- og PAdES-byggerne og de lokale og PKCS#11-providerne leveres alle i HotPDF Delphi PDF-komponenten