Artigo Técnico

HotPDF CSC: assinatura remota de PDF na nuvem com Delphi

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

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, o URL completo, um header Authorization bearer pronto, o body JSON, uma IdempotencyKey e o número da tentativa, e você retorna StatusCode, Body, RetryAfterMS mais um dos quatro valores de status cts enquanto a key nunca sai do HSM
O provider classifica status codes por conta própria, então um transporte que transforma um 503 respondido numa falha permanente desliga silenciosamente a lógica de retry, enquanto proxies e política de TLS ficam em código que você possui

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

Diagrama do ciclo de vida da SAD no HotPDF: com RequireSAD e AutoAuthorize o provider carrega credentials/info uma vez, pede valores de OTP ou PIN ao authentication callback, posta credentials/authorize, consulta credentials/authorizeCheck até 60 vezes a 250 ms quando a resposta é 202, e limpa a Signature Activation Data no momento em que signatures/signHash é aceito, mantendo uma SAD obtida enquanto a rede caiu antes da aceitação
Uma SAD que fica na memória é uma autorização esperando para ser gasta no documento errado, e uma SAD pré-definida passada via options é usada só para um 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
      // 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 ctsPermanentFailure terminam a chamada com spsProviderError, e o error_description (ou error) do serviço cai no LastError
  • 408, 429, 5xx e ctsTemporaryFailure são reintentados até RetryLimit (default 2), esperando o Retry-After ou o RetryBaseDelayMS (100 ms) dobrado a cada tentativa, limitado a MaxRetryAfterMS (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 o EnableIdempotency está ligado; desligue-o e um timeout depois do envio é final, porque ninguém consegue dizer se o hash já foi assinado
Diagrama da política de retry do HotPDF: toda chamada retentável carrega uma idempotency key determinística csc- hasheada do operation identifier e da fase, HTTP 401 força exatamente um token refresh, outras respostas 4xx terminam com spsProviderError, e 408, 429, 5xx ou uma falha temporária de transporte reintentam até RetryLimit de 2 enquanto esperam Retry-After ou backoff exponencial limitado a 5.000 ms
Lotes completos são cacheados por operation identifier, credential e fingerprint, e em modo assíncrono o responseID armazenado deixa uma chamada repetida retomar o polling em vez de reenviar o hash

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