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
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
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
ctsPermanentFailurebeëindigen de aanroep metspsProviderError, en deerror_description(oferror) van de dienst belandt inLastError - 408, 429, 5xx en
ctsTemporaryFailureworden herhaald totRetryLimit(default 2), met wachten opRetry-AfterofRetryBaseDelayMS(100 ms) verdubbeld per poging, afgekapt opMaxRetryAfterMS(5.000 ms) - Wachttijden lopen in slices van 25 ms die
Cancelcontroleren, dus een gebruiker die afbreekt hoeft geen back-off van vijf seconden uit te zitten signHashwordt alleen herhaald zolangEnableIdempotencyaan staat; zet hem uit en een timeout na verzending is definitief, want niemand kan zien of de hash al was ondertekend
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