Artigo Técnico

HotPDF CSC: assinatura remota de PDF na nuvem com Delphi

O HotPDF assina documentos PDF com uma chave privada guardada por um serviço remoto Cloud Signature Consortium (CSC) por meio do THPDFCSCSignatureProvider, um signature provider que conduz a API CSC — credential info, autorização, signatures/signHash e polling — enquanto a sua aplicação Delphi fornece o transporte HTTP e o access token OAuth. A chave nunca sai do HSM do serviço

Esse está cada vez mais sendo o único jeito de obter uma chave de assinatura qualified. Trust service providers entregam um endpoint CSC e um cliente OAuth, não um arquivo PFX nem um token USB, então não há nada para carregar num repositório de certificados local como faz a assinatura via cert store do Windows por CNG e CAPI. A integração ingênua falha de jeitos previsíveis: uma chamada signHash dá timeout e o retry assina o mesmo contrato duas vezes, ou um lote de quarenta notas dispara quarenta one-time passwords porque cada hash foi autorizado separadamente. Quase tudo que o provider faz é se defender dessas duas falhas

Por que o HotPDF deixa o HTTP para a sua aplicação?

Porque o transporte é exatamente onde cada deployment difere. Proxies, TLS pinning, certificados de cliente, vaults corporativos de OAuth e política de logging moram todos na camada HTTP, então o THPDFCSCSignatureProvider orquestra o estado do protocolo e chama uma função THPDFCSCTransport para cada request. O provider te entrega um THPDFCSCTransportRequest com Method (sempre POST), a URL completa construída de ServiceBaseURL mais o caminho do endpoint, um header bearer Authorization pronto, ContentType, o Body JSON, um IdempotencyKey, o número de Attempt e o MaxResponseBytes. Você preenche um THPDFCSCTransportResponse com StatusCode, Body e RetryAfterMS, e retorna um de ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure ou ctsCancelled

Diagrama da fronteira de transporte CSC do HotPDF: o THPDFCSCSignatureProvider orquestra o protocolo e entrega ao seu código um THPDFCSCTransportRequest com método POST, a URL completa, um header bearer Authorization pronto, o corpo JSON, um IdempotencyKey e o número da tentativa, e você retorna StatusCode, Body, RetryAfterMS mais um dos quatro valores de status cts enquanto a chave nunca sai do HSM
O provider classifica os status codes por conta própria, então um transporte que transforme um 503 respondido numa falha permanente desliga silenciosamente a lógica de retry, enquanto proxies e política de TLS ficam no código que é seu
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  // 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 socket ou DNS: retryável
        end;
        Response.StatusCode := HttpResp.StatusCode;   // reporta 503 como está, não classifique
        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;

A única regra que vale memorizar: retorne ctsSuccess sempre que um servidor realmente respondeu, mesmo com um 503. O provider classifica os status codes por conta própria, e um transporte que transforme um 429 em ctsPermanentFailure desliga silenciosamente a lógica de retry descrita adiante. O construtor é estrito no outro sentido — ele levanta EHPDFCSCSignatureProviderError quando o transporte falta, o CredentialID está vazio, nem um AccessToken nem um callback de token foram fornecidos, um budget está fora da faixa, ou o ServiceBaseURL não é HTTPS. http:// puro só é aceito com AllowInsecureHTTP, que pertence a um rig de teste e a lugar nenhum além dele

O que é a SAD, e por que o HotPDF a descarta depois de um uso?

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 por polling assíncrono. A SAD é a prova do serviço de que o signatário aprovou esses hashes específicos, e uma SAD que fica sobrando na memória é uma autorização esperando para ser gasta no documento errado

Com os defaults de THPDFCSCOptions.Default — RequireSAD e AutoAuthorize ambos True — o provider carrega o credentials/info uma vez, pede ao seu THPDFCSCAuthenticationCallback os valores de authData (um OTP, um PIN, o que for que o bloco auth do credential exija), e posta o credentials/authorize. Um 200 traz a SAD direto; um 202 traz um handle que é polado via credentials/authorizeCheck até MaxPollAttempts (60) vezes a cada PollIntervalMS (250 ms). 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. Se a rede cai antes de o signHash ser aceito, uma SAD obtida automaticamente é mantida para que o mesmo lote possa ser retentado sem perguntar ao signatário de novo

Diagrama do ciclo de vida da SAD no HotPDF: com RequireSAD e AutoAuthorize o provider carrega o credentials/info uma vez, pede ao callback de autenticação valores de OTP ou PIN, posta o credentials/authorize, pola o credentials/authorizeCheck até 60 vezes a 250 ms quando a resposta é 202, e limpa a Signature Activation Data no momento em que o signatures/signHash é aceito, mantendo uma SAD obtida quando a rede caiu antes da aceitação
Uma SAD que fica sobrando na memória é uma autorização esperando para ser gasta no documento errado, e uma SAD pré-definida passada pelas opções é usada só num request de hash único
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
      // seu cliente OAuth; ForceRefresh é True depois de o serviço responder 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   // sua UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

Uma SAD que você mesmo passa por Options.SAD se comporta diferente, e deliberadamente. O HotPDF não tem como saber para quais hashes ela foi emitida, então o provider usa uma SAD pré-definida só para um request de hash único. Para um lote com AutoAuthorize desligado, o provider falha com "CSC SAD is not pinned to the requested hash batch" em vez de adivinhar

Como o SignHashBatch assina muitos documentos com uma autorização?

O SignHashBatch envia um credentials/authorize e um signatures/signHash para até MaxBatchSignatures (64) digests, e monta os dois corpos a partir do mesmo array para que numSignatures, a ordem de hashes e o hashAlgorithmOID sejam idênticos nas duas chamadas. Esse match é o que o modelo multisign do CSC exige. Faça loop do método Sign de hash único quarenta vezes e você ganha quarenta autorizações; mande um authorize e um signHash que discordam e o serviço pode consumir a SAD contra o lote errado

Antes de qualquer tráfego de rede, o provider valida o lote. Cada request precisa ser um digest (sikDigest) de 1 a 1.024 bytes com um OID de digest, e todos os requests precisam compartilhar um mesmo OID de algoritmo de assinatura, um mesmo OID de digest e, para RSASSA-PSS, um mesmo salt length. Um lote multi-hash também carrega o credentials/info e retorna spsUnsupported quando o valor de multisign do credential é menor que o lote. A SAD então é pregada a uma fingerprint de lote — um SHA-256 sobre um rótulo de versão, a contagem e, por request, o OID do algoritmo, o OID do digest, o algoritmo, o salt length e os bytes do digest, cada um prefixado pelo comprimento. Troque dois hashes de lugar e é um lote diferente, que precisa de uma autorização nova

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: valores SHA-256 que você calculou
  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;
  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] pertence a Digests[I]; a contagem foi conferida contra o request
end;

Para RSASSA-PSS o provider também envia signAlgoParams, uma estrutura DER RSASSA-PSS-params em base64 com o algoritmo de hash, MGF1 e salt length. Montá-la significa codificar OIDs, e a versão 2.748.5 consertou um canto disso: a X.690 §8.19.4 dobra os dois primeiros arcos num único valor (40 × primeiro + segundo), e sob a raiz 2 um segundo arco acima de 39 empurra esse valor para além de 127, onde ele precisa da forma multi-byte base-128 que builds anteriores não aplicavam. Nenhum OID SHA-2 é afetado — 2.16 dobra para 96 — mas um OID malformado agora levanta o erro próprio do provider em vez de um EConvertError

Por que um request retentado não produz uma segunda assinatura?

O THPDFCSCSignatureProvider faz toda chamada retentável carregar uma idempotency key determinística e cacheia resultados completados, então um retry depois de uma resposta perdida retorna as assinaturas originais em vez de pedir novas ao HSM. A chave é csc- seguido do SHA-256 hex do identificador de operação e da fase, e a fase embute a fingerprint do lote tanto para a autorização quanto para o signHash. Fazer hash em vez de truncar importa: dois IDs de operação longos que compartilham um prefixo colidiriam sob truncamento, enquanto uma chave de comprimento fixo endereçada por conteúdo permanece única e estável entre tentativas

A política de retry no caminho de request compartilhado é estreita de propósito:

  • HTTP 401 força exatamente um refresh de token pelo callback de access token, então o request é repetido uma vez quando um callback de access token está atribuído; um segundo 401 é final
  • Outras respostas 4xx e ctsPermanentFailure encerram a chamada com spsProviderError, e o error_description do serviço cai no LastError
  • 408, 429, 5xx e ctsTemporaryFailure são retentados até RetryLimit (default 2), esperando Retry-After ou RetryBaseDelayMS × 2attempt (base de 100 ms), com teto em MaxRetryAfterMS (5.000 ms)
  • As esperas rodam em fatias de 25 ms que conferem o Cancel, então um usuário que aborta não fica sentado num back-off de cinco segundos
  • O signHash só é retentado enquanto o EnableIdempotency está ligado; desligue-o e um timeout depois do envio é final, porque ninguém consegue dizer se a chave já foi usada
Diagrama da política de retry do HotPDF: toda chamada retentável carrega uma idempotency key determinística csc- com hash do identificador de operação e da fase, HTTP 401 força exatamente um refresh de token, outras respostas 4xx terminam com spsProviderError, e 408, 429, 5xx ou uma falha temporária de transporte são retentados até o RetryLimit de 2 enquanto se espera Retry-After ou um backoff exponencial com teto de 5.000 ms
Lotes completados são cacheados por identificador de operação, credential e fingerprint, e no modo assíncrono o responseID guardado permite que uma chamada repetida retome o polling em vez de reenviar o hash

Assinatura assíncrona (operationMode "A", o default) acrescenta uma proteção a mais: o responseID é guardado antes de polar o signatures/signPolling, então uma chamada repetida com o mesmo identificador de operação retoma o polling em vez de reenviar. Lotes completados ficam num cache indexado por identificador de operação, credential e fingerprint, com teto de MaxOperationCacheEntries (128) e devolvidos como cópias profundas. Esse cache vive na instância do provider e não sobrevive a um restart. A idempotency key sobrevive, porque é derivada e não aleatória, então um processo reiniciado que reutiliza o identificador de operação dele envia a mesma chave — se o serviço deduplica por ela é promessa do serviço, não do HotPDF

Como colocar uma assinatura CSC num PDF?

Passe o provider ao HPDFCMSSignPDFStreamWithProvider junto com o certificado end-entity do GetCertificateChain; o HotPDF monta o CMS SignedData e o provider assina o digest dos signed attributes. O PDF de entrada precisa do placeholder /ByteRange e /Contents que o THPDFPage.AddSignedSignatureField escreve, exatamente como no fluxo de assinatura PAdES do HotPDF, e o modelo de provider é o mesmo coberto em providers de assinatura 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 certificado end-entity 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;

Dimensionar o placeholder /Contents passa pelo EstimateSignatureSize, que retorna o EstimatedSignatureBytes quando você o define e, caso contrário, o tamanho do modulus RSA a partir do comprimento da chave do credential. Para ECDSA defina o EstimatedSignatureBytes você mesmo, ou a estimativa reporta spsUnsupported. A variante de assinatura com tamanho automático re-assina quando um placeholder acaba pequeno demais, e só faz isso para providers que anunciam spcSafeSignRetry — o que o THPDFCSCSignatureProvider faz apenas enquanto o EnableIdempotency está ligado. Para fluxos PAdES-B-T, o TimestampDigest pede um timestamp token ao mesmo serviço via signatures/timestamp, com teto de MaxTimestampBytes (1 MB)

O que o provider CSC não faz?

Ele não assina mensagens, só digests. Ed25519 e Ed448 em modo puro entregam ao provider a mensagem inteira de signed attributes (sikMessage), e o validador de lote rejeita isso como malformado, porque o signHash é por definição baseado em hash. A unit do provider compila em Free Pascal com tipos de função simples no lugar de anonymous methods, mas os builders de CMS dirigidos por provider levantam exceção sob FPC hoje, então embutir uma assinatura CSC num PDF é um caminho Delphi

Ele também não decide política. O CredentialInfo reporta o status da chave, o status do certificado, o modo de autorização, o nível SCAL e o limite de multisign, mas o provider não vai recusar sozinho uma chave desabilitada ou um credential SCAL1 — confira isso antes de mostrar ao signatário o prompt de OTP. E uma instância de provider assina um lote por vez: o SignHashBatch é serializado internamente para que duas threads não disputem uma mesma SAD, o que significa que throughput vem de batching, não de compartilhar um provider entre worker threads. Se a assinatura resultante é qualified depende do trust service e do credential dele, não da biblioteca que levou o hash até lá

O provider CSC, os builders CMS e PAdES e os providers locais e PKCS#11 entram todos no componente HotPDF PDF para Delphi