Artículo técnico

Firma remota CSC con HotPDF: firmas PDF en la nube en Delphi

HotPDF firma documentos PDF con una clave privada custodiada por un servicio remoto de Cloud Signature Consortium (CSC) vía THPDFCSCSignatureProvider, un provider de firma que maneja la API CSC — credential info, autorización, signatures/signHash y polling — mientras su aplicación Delphi aporta el transporte HTTP y el access token OAuth. La clave nunca sale del HSM del servicio

Esa es cada vez más la única manera de conseguir una clave de firma cualificada, sencillamente. Los prestadores de servicios de confianza reparten un endpoint CSC y un cliente OAuth, no un archivo PFX ni un token USB, así que no hay nada que cargar en un almacén de certificados local a la manera de firmar con el almacén de certificados de Windows vía CNG y CAPI. La integración ingenua falla de formas previsibles: una llamada signHash da timeout y el reintento firma el mismo contrato dos veces, o un lote de cuarenta facturas dispara cuarenta contraseñas de un solo uso porque cada hash se autorizó por separado. La mayor parte de lo que hace el provider es defenderse de esos dos fallos

¿Por qué HotPDF deja el HTTP a su aplicación?

Porque el transporte es justo donde cada despliegue difiere. Los proxies, el TLS pinning, los certificados de cliente, las cajas fuertes OAuth corporativas y la política de logging viven todos en la capa HTTP, así que THPDFCSCSignatureProvider orquesta el estado del protocolo y llama a una función THPDFCSCTransport por cada petición. El provider le entrega un THPDFCSCTransportRequest con Method (siempre POST), la URL completa construida de ServiceBaseURL más la ruta del endpoint, una cabecera Authorization bearer lista, ContentType, el Body JSON, una IdempotencyKey, el número de Attempt y MaxResponseBytes. Usted rellena un THPDFCSCTransportResponse con StatusCode, Body y RetryAfterMS, y devuelve uno de ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure o ctsCancelled

Diagrama de la frontera de transporte CSC de HotPDF: THPDFCSCSignatureProvider orquesta el protocolo y entrega a su código un THPDFCSCTransportRequest con método POST, la URL completa, una cabecera Authorization bearer lista, el body JSON, una IdempotencyKey y el número de intento, y usted devuelve StatusCode, Body, RetryAfterMS más uno de los cuatro valores de estado cts mientras la clave nunca sale del HSM
El provider clasifica él mismo los códigos de estado, así que un transporte que convierta un 503 respondido en un fallo permanente desactiva en silencio la lógica de reintentos, mientras que los proxies y la política TLS se quedan en código suyo
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  // nombre de cabecera tal como lo documenta su servicio
          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 o DNS: reintentable
        end;
        Response.StatusCode := HttpResp.StatusCode;   // reportar 503 tal cual, no clasificar
        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;

La única regla que merece memorizarse: devuelva ctsSuccess siempre que un servidor haya contestado de verdad, aunque sea un 503. El provider clasifica los códigos de estado por su cuenta, y un transporte que convierta un 429 en ctsPermanentFailure desactiva en silencio la lógica de reintentos descrita más abajo. El constructor es estricto en la otra dirección: lanza EHPDFCSCSignatureProviderError cuando falta el transporte, CredentialID está vacío, no se suministra ni un AccessToken ni un callback de token, un budget está fuera de rango, o ServiceBaseURL no es HTTPS. El http:// llano solo se acepta con AllowInsecureHTTP, que pertenece a un banco de pruebas y a ningún otro sitio

¿Qué es el SAD y por qué HotPDF lo tira tras un solo uso?

THPDFCSCSignatureProvider trata la Signature Activation Data (SAD) como de un solo uso: se limpia del estado del provider en el momento en que signatures/signHash se acepta, incluso cuando la firma en sí llega después mediante polling asíncrono. El SAD es la prueba del servicio de que el firmante aprobó estos hashes concretos, y un SAD que se queda merodeando en memoria es una autorización esperando gastarse en el documento equivocado

Con los valores de THPDFCSCOptions.Default — RequireSAD y AutoAuthorize ambos True — el provider carga credentials/info una vez, pide a su THPDFCSCAuthenticationCallback los valores de authData (un OTP, un PIN, lo que el bloque auth del credential exija), y envía credentials/authorize. Un 200 trae el SAD directamente; un 202 trae un handle que se sondea vía credentials/authorizeCheck hasta MaxPollAttempts (60) veces a PollIntervalMS (250 ms). El callback puede devolver como máximo 32 valores, cada uno con un ID no vacío de hasta 256 bytes y un valor de hasta 4.096 bytes. Si la red se cae antes de que signHash se acepte, un SAD obtenido automáticamente se conserva para que el mismo lote pueda reintentarse sin volver a pedirle nada al firmante

Diagrama del ciclo de vida del SAD de HotPDF: con RequireSAD y AutoAuthorize el provider carga credentials/info una vez, pide al callback de autenticación valores de OTP o PIN, envía credentials/authorize, sondea credentials/authorizeCheck hasta 60 veces a 250 ms cuando la respuesta es 202, y limpia la Signature Activation Data en cuanto signatures/signHash se acepta, conservando un SAD obtenido si la red se cayó antes de la aceptación
Un SAD que merodea en memoria es una autorización esperando gastarse en el documento equivocado, y un SAD preajustado pasado vía options se usa solo para una petición de un solo hash
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, modo asíncrono, 2 reintentos
  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
      // su cliente OAuth; ForceRefresh es True tras una respuesta 401 del servicio
      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   // su UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

Un SAD que usted pasa por su cuenta vía Options.SAD se comporta distinto, y a propósito. HotPDF no puede saber para qué hashes se emitió, así que el provider usa un SAD preajustado solo para una petición de un solo hash. Para un lote con AutoAuthorize apagado, el provider falla con «CSC SAD is not pinned to the requested hash batch» en lugar de adivinar

¿Cómo firma SignHashBatch muchos documentos con una sola autorización?

SignHashBatch envía un credentials/authorize y un signatures/signHash para hasta MaxBatchSignatures (64) digests, y construye ambos cuerpos desde el mismo array, de modo que numSignatures, el orden de hashes y hashAlgorithmOID sean idénticos en las dos llamadas. Esa coincidencia es lo que exige el modelo multisign de CSC. Loopee el método Sign de un solo hash cuarenta veces y consigue cuarenta autorizaciones; envíe un authorize y un signHash que no concuerden y el servicio puede gastar el SAD contra el lote equivocado

Antes de cualquier tráfico de red, el provider valida el lote. Cada petición debe ser un digest (sikDigest) de 1 a 1.024 bytes con un OID de digest, y todas las peticiones deben compartir un OID de algoritmo de firma, un OID de digest y, para RSASSA-PSS, una longitud de salt. Un lote multi-hash también carga credentials/info y devuelve spsUnsupported cuando el valor multisign del credential es menor que el lote. El SAD se fija entonces a una huella de lote — un SHA-256 sobre una etiqueta de versión, el conteo y, por petición, el OID de algoritmo, el OID de digest, el algoritmo, la longitud de salt y los bytes del digest, cada uno con prefijo de longitud. Cambie dos hashes de sitio y es un lote distinto que necesita una autorización fresca

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  I: Integer;
begin
  SetLength(Requests, Length(Digests));      // Digests: valores SHA-256 que usted calculó
  for I := 0 to High(Digests) do
  begin
    Requests[I] := Default(THPDFSignatureProviderRequest);
    Requests[I].Algorithm := hsaRSAPKCS1v15;           // signAlgo se deriva cuando AlgorithmOID está vacío
    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] pertenece a Digests[I]; el conteo se cotejó contra la petición
end;

Para RSASSA-PSS el provider también envía signAlgoParams, una estructura DER en base64 RSASSA-PSS-params con el algoritmo de hash, MGF1 y la longitud de salt. Construirlo implica codificar OIDs, y la versión 2.748.5 arregló una esquina de eso: X.690 §8.19.4 pliega los dos primeros arcos en un valor (40 × primero + segundo), y bajo la raíz 2 un segundo arco por encima de 39 empuja ese valor más allá de 127, donde necesita la forma multibyte en base 128 que los builds anteriores no aplicaban. Ningún OID SHA-2 se ve afectado — 2.16 se pliega a 96 — pero un OID malformado ahora lanza el error propio del provider en lugar de un EConvertError

¿Por qué una petición reintentada no produce una segunda firma?

THPDFCSCSignatureProvider hace que cada llamada reintentable lleve una clave de idempotencia determinista y cachea los resultados completados, así que un reintento tras una respuesta perdida devuelve las firmas originales en lugar de pedirle nuevas al HSM. La clave es csc- seguido del SHA-256 hex del identificador de operación y la fase, y la fase incrusta la huella del lote tanto para la autorización como para signHash. Hashear en vez de truncar importa: dos identificadores de operación largos que compartieran prefijo colisionarían bajo truncado, mientras que una clave de longitud fija direccionada por contenido se mantiene única y estable entre intentos

La política de reintentos en el camino compartido de peticiones es estrecha a propósito:

  • Un HTTP 401 fuerza exactamente un refresco de token vía el callback de access token, y después la petición se repite una vez si hay callback de access token asignado; un segundo 401 es definitivo
  • El resto de respuestas 4xx y ctsPermanentFailure terminan la llamada con spsProviderError, y el error_description del servicio aterriza en LastError
  • 408, 429, 5xx y ctsTemporaryFailure se reintentan hasta RetryLimit (2 por defecto), esperando a Retry-After o a RetryBaseDelayMS × 2attempt (base de 100 ms), con tope en MaxRetryAfterMS (5.000 ms)
  • Las esperas corren en trozos de 25 ms que comprueban Cancel, así que un usuario que aborta no se traga un back-off de cinco segundos
  • signHash solo se reintenta con EnableIdempotency activado; apáguelo y un timeout tras el envío es definitivo, porque nadie puede saber si la clave ya se usó
Diagrama de la política de reintentos de HotPDF: cada llamada reintentable lleva una clave de idempotencia csc- determinista hasheada del identificador de operación y la fase, un HTTP 401 fuerza exactamente un refresco de token, las demás respuestas 4xx terminan con spsProviderError, y 408, 429, 5xx o un fallo temporal de transporte se reintentan hasta un RetryLimit de 2 esperando a Retry-After o a un backoff exponencial con tope de 5.000 ms
Los lotes completados se cachean por identificador de operación, credential y huella, y en modo asíncrono el responseID guardado permite que una llamada repetida retome el sondeo en vez de reenviar el hash

La firma asíncrona (operationMode «A», el valor por defecto) añade una guardia más: el responseID se guarda antes de sondear signatures/signPolling, así que una llamada repetida con el mismo identificador de operación retoma el sondeo en lugar de reenviar. Los lotes completados se sientan en una caché indexada por identificador de operación, credential y huella, con tope de MaxOperationCacheEntries (128) y devueltos como copias profundas. Esa caché vive en la instancia del provider y no sobrevive a un reinicio. La clave de idempotencia sí, porque es derivada y no aleatoria, así que un proceso reiniciado que reutiliza su identificador de operación envía la misma clave — si el servicio deduplica sobre ella es promesa del servicio, no de HotPDF

¿Cómo se mete una firma CSC en un PDF?

Pase el provider a HPDFCMSSignPDFStreamWithProvider junto con el certificado de entidad final de GetCertificateChain; HotPDF construye el CMS SignedData y el provider firma el digest de los signed attributes. El PDF de entrada necesita el placeholder de /ByteRange y /Contents que escribe THPDFPage.AddSignedSignatureField, exactamente como en el flujo de firma PAdES en HotPDF, y el modelo de provider es el mismo que cubre los providers de firma conectables de HotPDF para ML-DSA y 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 primero el certificado de entidad final
  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 el placeholder de /Contents pasa por EstimateSignatureSize, que devuelve EstimatedSignatureBytes si usted lo fijó y en caso contrario el tamaño del modulus RSA a partir de la longitud de la clave del credential. Para ECDSA fije usted EstimatedSignatureBytes, o la estimación reporta spsUnsupported. La variante de firma con tamaño automático refirma cuando un placeholder resulta demasiado pequeño, y solo lo hace con providers que anuncian spcSafeSignRetry — cosa que THPDFCSCSignatureProvider hace solo con EnableIdempotency activado. Para flujos PAdES-B-T, TimestampDigest pide un token de sellado de tiempo al mismo servicio vía signatures/timestamp, con tope de MaxTimestampBytes (1 MB)

¿Qué no hace el provider CSC?

No firma mensajes, solo digests. Ed25519 y Ed448 en modo puro le entregan al provider el mensaje entero de signed attributes (sikMessage), y el validador de lotes lo rechaza como malformado, porque signHash es por definición basado en hash. La unit del provider compila bajo Free Pascal con tipos de función llanos en lugar de métodos anónimos, pero los builders de CMS guiados por provider lanzan excepción hoy bajo FPC, así que incrustar una firma CSC en un PDF es un camino Delphi

Tampoco decide políticas. CredentialInfo informa del estado de la clave, del estado del certificado, del modo de autorización, del nivel SCAL y del límite de multisign, pero el provider no rechazará por su cuenta una clave deshabilitada ni un credential SCAL1 — compruébelo antes de mostrar a un firmante el diálogo del OTP. Y una instancia de provider firma un lote a la vez: SignHashBatch se serializa internamente para que dos hilos no puedan competir por un SAD, lo que significa que el throughput sale del batching, no de compartir un provider entre hilos de trabajo. Que la firma resultante sea cualificada depende del servicio de confianza y de su credential, no de la librería que llevó el hash hasta allí

El provider CSC, los builders CMS y PAdES y los providers locales y PKCS#11 llegan todos en el componente PDF HotPDF para Delphi