Um request signatures/signHash para um serviço de assinatura remoto pode dar timeout depois de o HSM já ter produzido a assinatura, e o cliente não consegue dizer de que lado dessa linha ele caiu. Reintente às cegas e uma aprovação pode render duas assinaturas no mesmo contrato. Recuse-se a reintentar e um lote noturno morre num único 503. Assine quarenta notas fiscais um hash por vez e o signatário digita quarenta senhas de uso único
Qualquer integração Cloud Signature Consortium (CSC) tem de resolver os dois. Trust service providers que emitem chaves qualificadas cada vez mais entregam um endpoint da API CSC e um cliente OAuth em vez de um arquivo PFX ou um USB token, então não há nada para importar num store local do jeito que assinatura de cert store do Windows via CNG e CAPI funciona. A key fica no HSM do provider, e toda assinatura é uma viagem de ida e volta por HTTPS
Por que o HotPDF deixa o HTTP para a sua aplicação?
O HotPDF assina através de um serviço CSC remoto com o THPDFCSCSignatureProvider, um signature provider que roda o protocolo CSC v2 (credential info, authorization, signatures/signHash, polling) enquanto o seu código Delphi possui o transporte HTTP e o token OAuth. Proxies, TLS pinning, certificados de cliente, token vaults e logging de requests moram todos na camada HTTP, e eles diferem em todo deployment
Para todo request o provider monta um THPDFCSCTransportRequest e chama a sua função THPDFCSCTransport. O record carrega o URL completo, um header Authorization bearer pronto, o Body JSON, uma IdempotencyKey, o OperationIdentifier e o número da Attempt. A sua função preenche um THPDFCSCTransportResponse com StatusCode, Body e RetryAfterMS, então retorna ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure ou ctsCancelled
Um transporte construído sobre o THTTPClient da RTL precisa de umas quarenta linhas. O body sai como bytes crus, então o JSON nunca passa por uma conversão de code page:
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 // nome do header como o seu serviço documenta
Headers := Headers + [TNameValuePair.Create('Idempotency-Key',
string(Request.IdempotencyKey))];
try
HttpResp := Client.Post(Request.URL, Body, Reply, Headers);
except
on ENetHTTPClientException do
Exit(ctsTemporaryFailure); // problema de DNS, TLS ou socket: retentável
end;
Response.StatusCode := HttpResp.StatusCode; // passe 429 e 503 sem tocar
SetLength(Response.Body, Reply.Size);
if Reply.Size > 0 then
Move(Reply.Memory^, Response.Body[1], Reply.Size);
// Retry-After em segundos; um valor HTTP-date cai para 0 aqui
Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
Result := ctsSuccess;
finally
Reply.Free;
Body.Free;
end;
end;
end;
A única regra para memorizar: retorne ctsSuccess sempre que um server respondeu, mesmo com um 503. O provider classifica status codes por conta própria, e um transporte que mapeia um 429 para ctsPermanentFailure desliga a lógica de retry descrita abaixo. Bodies maiores que MaxResponseBytes (2 MB por default) são rejeitados com spsMalformed não importando o que o transporte retorne
O construtor é estrito na outra direção. Ele levanta EHPDFCSCSignatureProviderError quando o transporte falta, ServiceBaseURL ou CredentialID está vazio, nem um AccessToken nem um token callback é fornecido, um budget está fora da faixa, ou o URL não é HTTPS. http:// puro passa só com AllowInsecureHTTP setado, e essa flag pertence a um rig de teste apontado para um mock service local
O que é o SAD, e por que ele é de uso único?
O THPDFCSCSignatureProvider trata a Signature Activation Data (SAD) como de uso único: ela é limpa do estado do provider no momento em que o signatures/signHash é aceito, mesmo quando a assinatura em si chega depois através de polling assíncrono. A SAD é a prova do serviço de que o signatário aprovou esses hashes específicos. Uma que fica na memória é uma autorização esperando para ser gasta no documento errado
Com os defaults do THPDFCSCOptions.Default (RequireSAD e AutoAuthorize ambos True), o provider carrega credentials/info uma vez, pede ao seu THPDFCSCAuthenticationCallback os valores authData que o bloco auth do credential exige, normalmente um OTP ou um PIN, e posta credentials/authorize. Uma resposta 200 carrega a SAD direto. Um 202 carrega um handle, que o provider consulta através de credentials/authorizeCheck até MaxPollAttempts (60) vezes a PollIntervalMS (250 ms) a menos que o serviço mande Retry-After. O callback pode retornar no máximo 32 valores, cada um com um ID não vazio de até 256 bytes e um valor de até 4.096 bytes
A limpeza acontece depois da aceitação. Se a rede cai antes de o signHash ser aceito, uma SAD obtida automaticamente fica no lugar, ainda amarrada ao mesmo lote, então o retry sai sem um segundo prompt de OTP. O signatário aprovou uma vez; o provider gasta essa aprovação uma vez
var
Options: THPDFCSCOptions;
Provider: THPDFCSCSignatureProvider;
begin
Options := THPDFCSCOptions.Default; // RequireSAD, AutoAuthorize, modo async, 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 é o seu próprio cliente OAuth; ForceRefresh é True depois de um 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 // UI sua
Exit(spsCancelled);
SetLength(Values, 1);
Values[0].ID := 'otp';
Values[0].Value := AnsiString(Otp);
Result := spsValid;
end);
O token callback dispara quando o token em cache está ausente ou dentro de TokenRefreshSkewSeconds (30) do expiry que você reportou; um ExpiresAtUTC de zero significa que o token é reutilizado até um 401 forçar um refresh
Uma SAD que você mesmo passa via Options.SAD se comporta diferente, e de propósito. Nada diz ao provider quais hashes aquela SAD cobre, então uma SAD pré-definida é usada só para um request de hash único. Para um lote multi-hash com AutoAuthorize ligado ela descarta o valor pré-definido e autoriza o lote; com AutoAuthorize desligado ela falha com "CSC SAD is not pinned to the requested hash batch" em vez de chutar. Serviços que autorizam pelo scope OAuth sozinho e nunca emitem uma SAD funcionam com RequireSAD definido para False, caso em que o membro SAD fica de fora do body do signHash
Assinando quarenta notas fiscais com um prompt de OTP
O SignHashBatch envia um credentials/authorize e um signatures/signHash para até MaxBatchSignatures (64) digests, e monta os dois bodies do mesmo array, então numSignatures, a ordem dos hashes e o hashAlgorithmOID concordam entre as duas chamadas. Faça um loop do método Sign de hash único quarenta vezes e você recebe quarenta autorizações. Envie um authorize e um signHash que discordam e o serviço pode consumir a SAD contra o lote errado
A validação roda antes de qualquer tráfego de rede. Todo request precisa ser um digest (sikDigest) de 1 a 1.024 bytes com um OID de digest, e todos os requests precisam compartilhar um algoritmo de assinatura, um OID de digest e, para RSASSA-PSS, um comprimento de salt. Um lote multi-hash também carrega credentials/info e retorna spsUnsupported quando o valor multisign do credential é menor que o tamanho do lote. A autorização então é amarrada a um fingerprint SHA-256 sensível à ordem da lista de requests, então trocar dois hashes faz um lote diferente que pede uma aprovação fresca
var
Requests: THPDFCSCSignatureRequests;
Signatures: THPDFCSCSignatures;
Status: THPDFSignatureProviderStatus;
OpId: AnsiString;
I: Integer;
begin
SetLength(Requests, Length(Digests)); // Digests: valores SHA-256 que você computou
for I := 0 to High(Digests) do
begin
Requests[I] := Default(THPDFSignatureProviderRequest);
Requests[I].Algorithm := hsaRSAPKCS1v15; // signAlgo derivado quando AlgorithmOID é vazio
Requests[I].DigestAlgorithmOID := '2.16.840.1.101.3.4.2.1';
Requests[I].InputKind := sikDigest;
Requests[I].Input := Digests[I];
end;
// Derive o ID do seu próprio run record, nunca de um GUID fresco por chamada
OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
Status := Provider.SignHashBatch(Requests, OpId, Signatures);
if Status = spsProviderError then
// Mesmo OpId: um lote completo volta do cache, um job async pendente
// retoma o polling, um request não enviado reutiliza a mesma key
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] pertence a Digests[I]; a contagem foi conferida contra o request
end;
Para RSASSA-PSS o provider adiciona signAlgoParams, uma estrutura DER base64 RSASSA-PSS-params carregando o algoritmo de hash, o MGF1 e o comprimento de salt. O X.690 §8.19.4 dobra os dois primeiros arcos de OID num único valor (40 × primeiro + segundo), e sob a raiz 2 um segundo arco acima de 39 empurra-o além de 127, onde ele precisa da forma multi-byte base-128. Nenhum OID SHA-2 exerce esse caminho, já que 2.16 dobra para 96, então um encoder pode passar em todo teste SHA-2 e ainda estar errado. O provider escreve a forma multi-byte e reporta um OID malformado como um EHPDFCSCSignatureProviderError dele em vez de um EConvertError
Por que um request reintentado não produz uma segunda assinatura?
O THPDFCSCSignatureProvider dá a toda chamada retentável uma idempotency key determinística e cacheia resultados completos, então um retry depois de uma resposta perdida retorna as assinaturas originais em vez de pedir novas ao HSM. A key é csc- seguido do hex SHA-256 do operation identifier e da fase de protocolo, e para authorization e signHash a fase inclui o fingerprint do lote. Hashear em vez de truncar mantém a key num comprimento fixo e impede que dois operation IDs longos com prefixo compartilhado colidam
A política de retry no caminho de request compartilhado é estreita:
- HTTP 401 força exatamente um token refresh pelo access-token callback, e o request é repetido uma vez; um segundo 401 é final, e sem token callback o primeiro é
- Outras respostas 4xx e
ctsPermanentFailureterminam a chamada comspsProviderError, e oerror_description(ouerror) do serviço cai noLastError - 408, 429, 5xx e
ctsTemporaryFailuresão reintentados atéRetryLimit(default 2), esperando oRetry-Afterou oRetryBaseDelayMS(100 ms) dobrado a cada tentativa, limitado aMaxRetryAfterMS(5.000 ms) - Esperas correm em fatias de 25 ms que conferem
Cancel, então um usuário que aborta não senta num back-off de cinco segundos - O
signHashé reintentado só enquanto oEnableIdempotencyestá ligado; desligue-o e um timeout depois do envio é final, porque ninguém consegue dizer se o hash já foi assinado
Assinatura assíncrona (operationMode "A", o default, com um validity_period de AsyncValidityPeriodMS, 60.000 ms) adiciona mais uma guarda. O responseID é armazenado antes de o provider começar a consultar o signatures/signPolling, então uma chamada repetida com o mesmo operation identifier retoma o job pendente em vez de submeter o hash de novo. Lotes completos são cacheados por instância de provider, até MaxOperationCacheEntries (128), e esse cache se vai depois de um restart. A idempotency key sobrevive, porque ela é derivada em vez de aleatória: um processo reiniciado que reutiliza o operation identifier dele manda a mesma key, e se o serviço deduplica sobre ela faz parte do contrato do serviço
Tudo isso depende do operation identifier, e o parâmetro é fácil de deixar vazio. Passe '' ao SignHashBatch e o provider cai para o OperationIdentifier nos requests, então para um GUID novo. Um GUID novo por chamada significa que o seu próprio loop de retry inicia uma operação nova toda vez: novo slot de cache, nova idempotency key, e possivelmente um segundo prompt de OTP. Também significa que o Provider.Cancel não tem nada para mirar, já que o seu código nunca viu o GUID. Derive o identifier de algo que você já persiste, como o número do lote ou o document ID, e mantenha o EnableIdempotency no default dele de True. Se um serviço rejeitar o header, derrube-o no transporte em vez de desligar a opção
Embutindo uma assinatura CSC num PDF
Passe o provider ao HPDFCMSSignPDFStreamWithProvider junto com o end-entity certificate do GetCertificateChain; o HotPDF monta o CMS SignedData e o provider assina o digest dos signed attributes. O PDF de input precisa do placeholder de /ByteRange e /Contents que o THPDFPage.AddSignedSignatureField escreve, exatamente como no workflow de assinatura PAdES no HotPDF, e o modelo de provider é o mesmo por trás dos signature providers plugáveis do HotPDF para ML-DSA e EdDSA
var
Chain: THPDFCSCCertificateChain;
SignOpts: THPDFCMSSignOptions;
Src, Dst: TFileStream;
begin
if Provider.RefreshCredentialInfo <> spsValid then
raise Exception.Create(Provider.LastError);
Chain := Provider.GetCertificateChain; // CSC lista o end-entity certificate primeiro
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;
Para dimensionamento de placeholder, o EstimateSignatureSize retorna EstimatedSignatureBytes quando você o define, senão o tamanho do módulo RSA derivado do comprimento de key no credentials/info. Para keys ECDSA ele responde spsUnsupported e o estimador de placeholder cai para o certificado, então definir o EstimatedSignatureBytes você mesmo dá um resultado previsível. O HPDFCMSSignPDFStreamWithProviderAutoSize reconstrói e re-assina quando um placeholder acaba pequeno demais, e o faz só para providers que anunciam spcSafeSignRetry. O THPDFCSCSignatureProvider o anuncia enquanto o EnableIdempotency está ligado, o que é mais uma razão para deixar esse default em paz
Para PAdES-B-T, o TimestampDigest pede ao mesmo serviço um token RFC 3161 através de signatures/timestamp, com um nonce opcional e um cap de tamanho de MaxTimestampBytes (1 MB)
Decisões de política que ficam no seu código
O provider assina digests. Ed25519 e Ed448 em modo pure entregam a um signature provider a mensagem inteira de signed attributes (sikMessage), e o validador de lote a rejeita como malformada, porque o signHash é baseado em hash por definição. Sob Free Pascal a unit do provider compila com function types simples no lugar de anonymous methods, então o SignHashBatch e o TimestampDigest são chamáveis ali; embutir o resultado num PDF através do HPDFCMSSignPDFStreamWithProvider é um caminho Delphi
O provider também deixa política para você. O CredentialInfo reporta KeyStatus, CertificateStatus, AuthorizationMode, SCAL e MultiSignatures, mas uma key desabilitada ou um credential SCAL1 é decisão sua rejeitar. Confira esses campos logo depois do RefreshCredentialInfo e antes de o signatário jamais ver um prompt de OTP; uma recusa do serviço depois de o signatário digitar um código é uma experiência pior que um erro claro de cara
Uma instância de provider roda um lote por vez, porque o SignHashBatch, o RefreshCredentialInfo e o TimestampDigest dividem um lock para que duas threads não disputem uma SAD. Throughput vem de lotes maiores, até o limite multisign do credential. Se a assinatura resultante conta como qualificada é decidido pelo trust service e pelo credential que ele emitiu
O provider CSC vem junto dos builders CMS e PAdES e dos signature providers locais e PKCS#11 no HotPDF Delphi PDF component, então assinatura remota, baseada em token e baseada em arquivo alimentam as mesmas chamadas de assinatura PDF