Teknisk artikel

HotPDF CSC-fjärrsignering: moln-PDF-signaturer i Delphi

HotPDF signerar PDF-dokument med en privat nyckel som hålls av en fjärransluten Cloud Signature Consortium-tjänst (CSC) genom THPDFCSCSignatureProvider, en signaturprovider som driver CSC-API:t — credential info, auktorisering, signatures/signHash och polling — medan din Delphi-applikation levererar HTTP-transporten och OAuth-åtkomsttoken. Nyckeln lämnar aldrig tjänstens HSM

Det är i ökande grad den enda vägen att överhuvudtaget få en kvalificerad signeringsnyckel. Trust service providers delar ut en CSC-endpoint och en OAuth-klient, inte en PFX-fil eller en USB-token, så det finns ingenting att läsa in i ett lokalt certifikatarkiv på det sätt Windows cert store-signering genom CNG och CAPI gör. Den naiva integrationen fallerar på förutsägbara sätt: ett signHash-anrop får timeout och omförsöket signerar samma kontrakt två gånger, eller så utlöser en sats på fyrtio fakturor fyrtio engångslösenord för att varje hash auktoriserades separat. Det mesta providern gör är att försvara sig mot de två felen

Varför lämnar HotPDF HTTP åt din applikation?

För att transporten är exakt där varje driftsättning skiljer sig. Proxies, TLS pinning, klientcertifikat, företags OAuth-valv och loggningspolicy bor alla i HTTP-lagret, så THPDFCSCSignatureProvider orkestrerar protokolltillståndet och anropar en THPDFCSCTransport-funktion för varje request. Providern räcker dig en THPDFCSCTransportRequest med Method (alltid POST), den fullständiga URL:en byggd av ServiceBaseURL plus endpoint-sökvägen, en färdig Authorization-bearer-header, ContentType, JSON-Body, en IdempotencyKey, Attempt-numret och MaxResponseBytes. Du fyller en THPDFCSCTransportResponse med StatusCode, Body och RetryAfterMS och returnerar en av ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure eller ctsCancelled

HotPDF CSC transportgränsdiagram: THPDFCSCSignatureProvider orkestrerar protokollet och räcker din kod en THPDFCSCTransportRequest med en POST-metod, den fullständiga URL:en, en färdig Authorization-bearer-header, JSON-body, en IdempotencyKey och försöksnumret, och du returnerar StatusCode, Body, RetryAfterMS plus en av de fyra cts-statusvärdena medan nyckeln aldrig lämnar HSM:en
Providern klassificerar statuskoder själv, så en transport som gör ett besvarat 503 till ett permanent fel tyst avaktiverar retry-logiken, medan proxies och TLS-policy stannar i kod du äger
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  // headernamn som din tjänst dokumenterar 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-trubbel: går att göra om
        end;
        Response.StatusCode := HttpResp.StatusCode;   // rapportera 503 som den är, klassificera inte
        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 enda regel värd att memorera: returnera ctsSuccess närhelst en server faktiskt svarade, även med en 503. Providern klassificerar statuskoder själv, och en transport som gör en 429 till ctsPermanentFailure tyst avaktiverar retry-logiken som beskrivs nedan. Konstruktorn är strikt åt andra hållet — den kastar EHPDFCSCSignatureProviderError när transporten saknas, CredentialID är tom, varken en AccessToken eller en token-callback har lämnats, en budget är utanför intervallet, eller ServiceBaseURL inte är HTTPS. Ren http:// accepteras bara med AllowInsecureHTTP, som hör hemma i en testrigg och ingenstans annars

Vad är SAD, och varför kastar HotPDF bort den efter ett användande?

THPDFCSCSignatureProvider behandlar Signature Activation Data (SAD) som single-use: den rensas ur providerns tillstånd i samma stund som signatures/signHash accepteras, även när signaturen själv anländer senare genom asynkron polling. SAD:en är tjänstens bevis för att signeraren godkänt just dessa hashar, och en SAD som ligger kvar i minnet är en auktorisering som väntar på att spenderas på fel dokument

Med defaultvärdena från THPDFCSCOptions.Default — RequireSAD och AutoAuthorize båda True — läser providern in credentials/info en gång, ber din THPDFCSCAuthenticationCallback om authData-värdena (en OTP, en PIN, vad än credentialens auth-block kräver) och postar credentials/authorize. En 200 bär SAD:en direkt; en 202 bär ett handtag som pollas genom credentials/authorizeCheck upp till MaxPollAttempts (60) gånger vid PollIntervalMS (250 ms). Callbacken får returnera högst 32 värden, vart och ett med ett icke-tomt ID på upp till 256 byte och ett värde på upp till 4 096 byte. Om nätverket bryts innan signHash accepteras behålls en automatiskt erhållen SAD så att samma sats kan göras om utan att fråga signeraren igen

HotPDF SAD-livscykeldiagram: med RequireSAD och AutoAuthorize läser providern in credentials/info en gång, ber autentiseringscallbacken om OTP- eller PIN-värden, postar credentials/authorize, pollar credentials/authorizeCheck upp till 60 gånger vid 250 ms när svaret är 202, och rensar Signature Activation Data i samma stund som signatures/signHash accepteras, medan en erhållen SAD behålls om nätverket bröts före acceptansen
En SAD som ligger kvar i minnet är en auktorisering som väntar på att spenderas på fel dokument, och en förinställd SAD som skickas genom options används bara för ett enkelhash-request
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, asynkronläge, 2 omförsök
  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 är True efter att tjänsten svarat 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 själv skickar in genom Options.SAD beter sig annorlunda, och med flit. HotPDF kan inte veta vilka hashar den utfärdades för, så providern använder en förinställd SAD bara för ett enkelhash-request. För en sats med AutoAuthorize avstängt fallerar providern med "CSC SAD is not pinned to the requested hash batch" i stället för att gissa

Hur signerar SignHashBatch många dokument med en auktorisering?

SignHashBatch skickar en credentials/authorize och en signatures/signHash för upp till MaxBatchSignatures (64) digestar, och bygger båda body:erna ur samma array så att numSignatures, ordningen på hashes och hashAlgorithmOID är identiska i de två anropen. Den matchen är vad CSC-multisign-modellen kräver. Loopa enkelhash-Sign-metoden fyrtio gånger och du får fyrtio auktoriseringar; skicka en authorize och en signHash som inte överensstämmer och tjänsten kan konsumera SAD:en mot fel sats

Innan någon nätverkstrafik validerar providern satsen. Varje request måste vara en digest (sikDigest) på 1 till 1 024 byte med en digest-OID, och alla requests måste dela en signaturalgoritm-OID, en digest-OID och, för RSASSA-PSS, en saltlängd. En flerhashsats läser också in credentials/info och returnerar spsUnsupported när credentialens multisign-värde är mindre än satsen. SAD:en fästs sedan vid ett satsfingeravtryck — en SHA-256 över en versionsetikett, antalet och, per request, algoritm-OID, digest-OID, algoritm, saltlängd och digestbyte, vart och ett längdprefixat. Byt plats på två hashar och det är en annan sats som behöver en färsk auktorisering

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: SHA-256-värden du beräknat
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo härleds när AlgorithmOID är tomt
    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ör till Digests[I]; antalet kontrollerades mot requesten
end;

För RSASSA-PSS skickar providern också signAlgoParams, en base64 DER RSASSA-PSS-params-struktur med hashalgoritmen, MGF1 och saltlängd. Att bygga den innebär att koda OID:er, och version 2.748.5 fixade ett hörn av det: X.690 §8.19.4 viker ihop de två första bågarna till ett värde (40 × första + andra), och under 2-roten trycker en andra båge över 39 det värdet förbi 127, där det behöver bas-128-flerbyteformen som tidigare byggen inte tillämpade. Ingen SHA-2-OID påverkas — 2.16 viker ihop till 96 — men en felformaterad OID kastar nu providerns eget fel i stället för ett EConvertError

Varför ger en omkörd request inte en andra signatur?

THPDFCSCSignatureProvider låter varje försökbart anrop bära en deterministisk idempotensnyckel och cachar färdiga resultat, så ett omförsök efter ett förlorat svar returnerar originalsignaturerna i stället för att be HSM:en om nya. Nyckeln är csc- följt av hex-SHA-256 av operationidentifieraren och fasen, och fasen bäddar in satsfingeravtrycket för både auktorisering och signHash. Att hasha i stället för att trunkera spelar roll: två långa operation-ID:n som delar ett prefix skulle kollidera under trunkering, medan en fast längd innehållsadresserad nyckel förblir unik och stabil över försök

Retry-policyn i den delade requestvägen är smal med flit:

  • HTTP 401 tvingar fram exakt en tokenuppdatering genom access-token-callbacken, sedan upprepas requesten en gång när en access-token-callback är tilldelad; en andra 401 är slutgiltig
  • Andra 4xx-svar och ctsPermanentFailure avslutar anropet med spsProviderError, och tjänstens error_description landar i LastError
  • 408, 429, 5xx och ctsTemporaryFailure görs om upp till RetryLimit (standard 2), med väntan på Retry-After eller RetryBaseDelayMS × 2försök (100 ms bas), takade vid MaxRetryAfterMS (5 000 ms)
  • Väntan löper i 25 ms-skivor som kontrollerar Cancel, så en användare som avbryter sitter inte igenom en femsekunders back-off
  • signHash görs om bara medan EnableIdempotency är på; stäng av den och en timeout efter inskickning är slutgiltig, för att ingen kan säga om nyckeln redan använts
HotPDF-diagram för retry-policy: varje försökbart anrop bär en deterministisk csc- idempotensnyckel hashad av operationidentifieraren och fasen, HTTP 401 tvingar fram exakt en tokenuppdatering, andra 4xx-svar slutar med spsProviderError, och 408, 429, 5xx eller ett tillfälligt transportfel görs om upp till RetryLimit 2 med väntan på Retry-After eller exponentiell backoff takad vid 5 000 ms
Färdiga satser cachas av operationidentifierare, credential och fingeravtryck, och i asynkronläge låter den lagrade responseID:en ett upprepat anrop återuppta pollingen i stället för att skicka in hashen igen

Asynkron signering (operationMode "A", standarden) lägger till en bevakning till: responseID lagras innan polling av signatures/signPolling, så ett upprepat anrop med samma operationidentifierare återupptar pollingen i stället för att skicka in igen. Färdiga satser ligger i en cache nycklad av operationidentifierare, credential och fingeravtryck, takad av MaxOperationCacheEntries (128) och returnerade som djupa kopior. Den cachen lever i providerinstansen och överlever inte en omstart. Idempotensnyckeln gör det, för att den härleds snarare än slumpas, så en omstartad process som återanvänder sin operationidentifierare skickar samma nyckel — huruvida tjänsten deduplicerar på den är tjänstens löfte, inte HotPDF:s

Hur får du in en CSC-signatur i en PDF?

Skicka providern till HPDFCMSSignPDFStreamWithProvider tillsammans med slutentitetscertifikatet från GetCertificateChain; HotPDF bygger CMS SignedData och providern signerar digesten av de signerade attributen. Indata-PDF:en behöver platshållarna /ByteRange och /Contents som THPDFPage.AddSignedSignatureField skriver, exakt som i PAdES-signeringsarbetsflödet i HotPDF, och providermodellen är samma som täcks i HotPDF pluggbara signaturproviders för ML-DSA och EdDSA

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

Storlekssättning av /Contents-platshållaren går genom EstimateSignatureSize, som returnerar EstimatedSignatureBytes när du satt den och i övrigt RSA-modulstorleken ur credentialens nyckellängd. För ECDSA sätter du EstimatedSignatureBytes själv, annars rapporterar uppskattningen spsUnsupported. Auto-size-signeringsvarianten signerar om när en platshållare visar sig för liten, och den gör det bara för providers som utlovar spcSafeSignRetry — vilket THPDFCSCSignatureProvider gör bara medan EnableIdempotency är på. För PAdES-B-T-arbetsflöden begär TimestampDigest en tidsstämpeltoken från samma tjänst genom signatures/timestamp, takad vid MaxTimestampBytes (1 MB)

Vad gör CSC-providern inte?

Den signerar inte meddelanden, bara digestar. Ed25519 och Ed448 i pure mode räcker providern hela det signerade attributmeddelandet (sikMessage), och satsvalideraren avvisar det som felformaterat, för att signHash per definition är hashbaserad. Provideruniten kompilerar under Free Pascal med vanliga funktionstyper i stället för anonyma metoder, men providerdrivna CMS-byggare kastar under FPC i dag, så att bädda in en CSC-signatur i en PDF är en Delphi-väg

Den avgör inte heller policy. CredentialInfo rapporterar nyckelstatus, certifikatstatus, auktoriseringsläge, SCAL-nivå och multisign-gräns, men providern vägrar inte av sig själv en avstängd nyckel eller en SCAL1-credential — kontrollera det innan du visar en signerare OTP-frågan. Och en providerinstans signerar en sats i taget: SignHashBatch serialiseras internt så att två trådar inte kan tävla om en SAD, vilket betyder att genomströmningen kommer från batchning, inte från att dela en provider över arbetstrådar. Huruvida den resulterande signaturen är kvalificerad beror på trust service och dess credential, inte på biblioteket som bar hashen dit

CSC-providern, CMS- och PAdES-byggarna och de lokala och PKCS#11-baserade providers kommer alla i HotPDF Delphi PDF-komponenten