Artigo Técnico

Assinatura remota CSC no HotPDF: assinaturas PDF na nuvem

O HotPDF assina documentos PDF com uma chave privada guardada num serviço remoto da Cloud Signature Consortium (CSC) através 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 token de acesso OAuth. A chave nunca sai do HSM do serviço

Essa é cada vez mais a única maneira de obter uma chave de assinatura qualificada. Os prestadores de serviços de confiança entregam um endpoint CSC e um cliente OAuth, não um ficheiro PFX nem um token USB, por isso não há nada para carregar numa loja de certificados local à maneira da assinatura pela loja de certificados do Windows via CNG e CAPI. A integração ingénua falha de maneiras previsíveis: uma chamada signHash excede o tempo e a repetição assina o mesmo contrato duas vezes, ou um lote de quarenta faturas dispara quarenta códigos de uso único porque cada hash foi autorizado em separado. A maior parte do que o provider faz é defender-se contra essas duas falhas

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

Porque o transporte é exatamente o sítio onde cada deployment difere. Proxies, TLS pinning, certificados de cliente, cofres OAuth empresariais e política de logging vivem todos na camada HTTP, por isso o THPDFCSCSignatureProvider orquestra o estado do protocolo e chama uma função THPDFCSCTransport por cada pedido. O provider entrega-lhe um THPDFCSCTransportRequest com Method (sempre POST), o URL completo construído a partir de ServiceBaseURL mais o caminho do endpoint, um cabeçalho Authorization bearer pronto, ContentType, o Body JSON, um IdempotencyKey, o número Attempt e MaxResponseBytes. Preenche um THPDFCSCTransportResponse com StatusCode, Body e RetryAfterMS, e devolve um de ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure ou ctsCancelled

Diagrama da fronteira de transporte CSC no HotPDF: o THPDFCSCSignatureProvider orquestra o protocolo e entrega ao seu código um THPDFCSCTransportRequest com método POST, o URL completo, um cabeçalho Authorization bearer pronto, o corpo JSON, um IdempotencyKey e o número de tentativa, e você devolve StatusCode, Body, RetryAfterMS mais um dos quatro valores de estado cts enquanto a chave nunca sai do HSM
O provider classifica os códigos de estado ele próprio, por isso um transporte que transforme um 503 respondido numa falha permanente desliga em silêncio a lógica de repetição, enquanto proxies e política de TLS ficam em 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 cabeçalho tal como o seu serviço 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);            // problemas de socket ou DNS: repetível
        end;
        Response.StatusCode := HttpResp.StatusCode;   // reportar 503 tal como vem, sem classificar
        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 regra única que vale a pena decorar: devolva ctsSuccess sempre que um servidor realmente respondeu, mesmo com um 503. O provider classifica os códigos de estado ele próprio, e um transporte que transforme um 429 em ctsPermanentFailure desliga em silêncio a lógica de repetição descrita abaixo. O construtor é estrito na outra direção — levanta EHPDFCSCSignatureProviderError quando falta o transporte, o CredentialID está vazio, nem um AccessToken nem um callback de token são fornecidos, um budget está fora do intervalo, ou o ServiceBaseURL não é HTTPS. O http:// simples só é aceite com AllowInsecureHTTP, que pertence a um rig de testes e a mais nenhum sítio

O que é o SAD, e porque o HotPDF o deita fora depois de um uso?

O THPDFCSCSignatureProvider trata a Signature Activation Data (SAD) como de uso único: é limpa do estado do provider no momento em que o signatures/signHash é aceite, mesmo quando a assinatura em si chega mais tarde por polling assíncrono. A SAD é a prova do serviço de que o assinante aprovou aqueles hashes concretos, e uma SAD que persiste em memória é uma autorização à espera de ser gasta no documento errado

Com os valores por defeito de THPDFCSCOptions.Default — RequireSAD e AutoAuthorize ambos True — o provider carrega o credentials/info uma vez, pede ao seu THPDFCSCAuthenticationCallback os valores authData (um OTP, um PIN, o que quer que o bloco auth da credencial exija), e publica credentials/authorize. Um 200 traz a SAD diretamente; um 202 traz um handle que é polled através de credentials/authorizeCheck até MaxPollAttempts (60) vezes a PollIntervalMS (250 ms). O callback pode devolver 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 cair antes de o signHash ser aceite, uma SAD obtida automaticamente é guardada para que o mesmo lote possa ser repetido sem voltar a pedir ao assinante

Diagrama do ciclo de vida do SAD no HotPDF: com RequireSAD e AutoAuthorize o provider carrega credentials/info uma vez, pede valores OTP ou PIN ao callback de autenticação, publica credentials/authorize, faz polling de credentials/authorizeCheck até 60 vezes a 250 ms quando a resposta é 202, e limpa a Signature Activation Data no momento em que signatures/signHash é aceite, guardando uma SAD obtida enquanto a rede caiu antes da aceitação
Uma SAD que persiste em memória é uma autorização à espera de ser gasta no documento errado, e uma SAD pré-definida passada nas opções é usada só para um pedido de hash único
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, modo assíncrono, 2 repetições
  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
      // o 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   // a sua UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

Uma SAD que passe você próprio através de Options.SAD comporta-se de outra forma, e deliberadamente. O HotPDF não pode saber para que hashes foi emitida, por isso o provider usa uma SAD pré-definida só para um pedido de hash único. Numa bateria com AutoAuthorize desligado, o provider falha com «CSC SAD is not pinned to the requested hash batch» em vez de adivinhar

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

O SignHashBatch envia um credentials/authorize e um signatures/signHash para até MaxBatchSignatures (64) digests, e constrói ambos os corpos a partir do mesmo array, de modo que numSignatures, a ordem de hashes e o hashAlgorithmOID sejam idênticos nas duas chamadas. Essa correspondência é o que o modelo multisign do CSC exige. Fazer um ciclo no método de hash único Sign quarenta vezes dá quarenta autorizações; enviar um authorize e um signHash que discordam pode fazer o serviço gastar a SAD contra o lote errado

Antes de qualquer tráfego de rede, o provider valida o lote. Cada pedido tem de ser um digest (sikDigest) de 1 a 1.024 bytes com um OID de digest, e todos os pedidos têm de partilhar um OID de algoritmo de assinatura, um OID de digest e, para RSASSA-PSS, um comprimento de sal. Uma bateria multi-hash também carrega o credentials/info e devolve spsUnsupported quando o valor multisign da credencial é menor que o lote. A SAD é depois cravada numa impressão digital do lote — um SHA-256 sobre um rótulo de versão, a contagem e, por pedido, o OID de algoritmo, o OID de digest, o algoritmo, o comprimento de sal e os bytes do digest, cada um prefixado com o comprimento. Trocar dois hashes é um lote diferente que precisa de autorização fresca

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: valores SHA-256 que calculou
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo derivado quando AlgorithmOID está 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 verificada contra o pedido
end;

Para RSASSA-PSS o provider também envia signAlgoParams, uma estrutura RSASSA-PSS-params DER em base64 com o algoritmo de hash, o MGF1 e o comprimento de sal. Construí-la significa codificar OIDs, e a versão 2.748.5 corrigiu um canto disso: o 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 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 do próprio provider em vez de um EConvertError

Porque é que um pedido repetido não produz uma segunda assinatura?

O THPDFCSCSignatureProvider faz cada chamada repetível transportar uma chave de idempotência determinística e põe em cache resultados concluídos, por isso uma repetição depois de uma resposta perdida devolve 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 incorpora a impressão digital do lote tanto para a autorização como para o signHash. Fazer hash em vez de truncar interessa: dois identificadores de operação longos que partilhem um prefixo colidiriam sob truncamento, enquanto uma chave de comprimento fixo endereçada pelo conteúdo permanece única e estável entre tentativas

A política de repetição no caminho de pedidos partilhado é estreita de propósito:

  • HTTP 401 força exatamente uma renovação de token através do callback de token de acesso, e o pedido é repetido uma vez quando existe um callback de token de acesso atribuído; um segundo 401 é final
  • Outras respostas 4xx e ctsPermanentFailure terminam a chamada com spsProviderError, e o error_description do serviço aterra no LastError
  • 408, 429, 5xx e ctsTemporaryFailure são repetidos até RetryLimit (por defeito 2), esperando Retry-After ou RetryBaseDelayMS × 2attempt (base de 100 ms), com teto em MaxRetryAfterMS (5.000 ms)
  • As esperas correm em fatias de 25 ms que verificam Cancel, por isso um utilizador que aborta não fica sentado num back-off de cinco segundos
  • O signHash só é repetido com EnableIdempotency ligado; desligue-o e um timeout depois da submissão é final, porque ninguém consegue dizer se a chave já foi usada
Diagrama da política de repetição no HotPDF: cada chamada repetível transporta uma chave de idempotência csc- determinística com hash do identificador de operação e da fase, HTTP 401 força exatamente uma renovação de token, outras respostas 4xx terminam com spsProviderError, e 408, 429, 5xx ou uma falha temporária de transporte repetem até um RetryLimit de 2 enquanto esperam Retry-After ou um backoff exponencial com teto em 5.000 ms
Os lotes concluídos são postos em cache por identificador de operação, credencial e impressão digital, e no modo assíncrono o responseID guardado deixa uma chamada repetida retomar o polling em vez de resubmeter o hash

A assinatura assíncrona (operationMode «A», o valor por defeito) acrescenta uma guarda: o responseID é guardado antes de fazer polling ao signatures/signPolling, por isso uma chamada repetida com o mesmo identificador de operação retoma o polling em vez de resubmeter. Lotes concluídos ficam numa cache chaveada por identificador de operação, credencial e impressão digital, limitada por MaxOperationCacheEntries (128) e devolvida como cópias profundas. Essa cache vive na instância do provider e não sobrevive a um reinício. A chave de idempotência sobrevive, porque é derivada e não aleatória, por isso um processo reiniciado que reutilize o seu identificador de operação envia a mesma chave — se o serviço deduplica com base nela é uma promessa do serviço, e não do HotPDF

Como se põe uma assinatura CSC num PDF?

Passe o provider ao HPDFCMSSignPDFStreamWithProvider juntamente com o certificado de entidade final de GetCertificateChain; o HotPDF constrói o CMS SignedData e o provider assina o digest dos atributos assinados. O PDF de entrada precisa do placeholder /ByteRange e /Contents que o THPDFPage.AddSignedSignatureField escreve, exatamente como no fluxo de assinatura PAdES no HotPDF, e o modelo de provider é o mesmo coberto em os signature providers ligá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;   // O CSC lista o certificado de entidade final 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 devolve EstimatedSignatureBytes quando o define e caso contrário o tamanho do módulo RSA a partir do comprimento da chave da credencial. Para ECDSA defina EstimatedSignatureBytes você próprio, ou a estimativa reporta spsUnsupported. A variante de assinatura com dimensionamento automático assina de novo quando um placeholder revela ser pequeno de mais, e só o faz para providers que anunciem spcSafeSignRetry — o que o THPDFCSCSignatureProvider faz só com EnableIdempotency ligado. Para fluxos PAdES-B-T, o TimestampDigest pede um token de carimbo temporal ao mesmo serviço através de signatures/timestamp, limitado a MaxTimestampBytes (1 MB)

O que é que o provider CSC não faz?

Não assina mensagens, só digests. Ed25519 e Ed448 em modo puro entregam ao provider a mensagem inteira de atributos assinados (sikMessage), e o validador de baterias rejeita-a como malformada, 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 conduzidos por provider levantam exceção em FPC hoje, por isso incorporar uma assinatura CSC num PDF é um caminho Delphi

Também não decide políticas. O CredentialInfo reporta o estado da chave, o estado do certificado, o modo de autorização, o nível SCAL e o limite multisign, mas o provider não vai recusar sozinho uma chave desativada nem uma credencial SCAL1 — verifique isso antes de mostrar ao assinante o pedido de OTP. E uma instância de provider assina um lote de cada vez: o SignHashBatch é serializado internamente para que duas threads não disputem uma SAD, o que significa que o throughput vem do batching, e não de partilhar um provider por threads de trabalho. Se a assinatura resultante é qualificada depende do serviço de confiança e da sua credencial, e não da biblioteca que transportou o hash até lá

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