Artículo técnico

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

HotPDF firma documentos PDF con una clave privada custodiada por un servicio remoto del Cloud Signature Consortium (CSC) mediante THPDFCSCSignatureProvider, un signature provider 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 de OAuth. La clave jamás sale del HSM del servicio

Cada vez más, esa es la única manera de conseguir siquiera una clave de firma calificada. Los proveedores de servicios de confianza entregan 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 al estilo de la firma con el almacén de certificados de Windows vía CNG y CAPI. La integración ingenua falla de maneras predecibles: una llamada signHash expira 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 contra esas dos fallas

¿Por qué HotPDF deja el HTTP en manos de su aplicación?

Porque el transporte es justo donde cada despliegue difiere. Los proxies, el TLS pinning, los certificados de cliente, las bóvedas corporativas de OAuth 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 request. El provider le entrega un THPDFCSCTransportRequest con Method (siempre POST), la URL completa armada con ServiceBaseURL más la ruta del endpoint, un header bearer Authorization listo, ContentType, el Body JSON, un IdempotencyKey, el número de Attempt y MaxResponseBytes. Usted llena un THPDFCSCTransportResponse con StatusCode, Body y RetryAfterMS, y devuelve uno de ctsSuccess, ctsTemporaryFailure, ctsPermanentFailure o ctsCancelled

Diagrama de 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, un header bearer Authorization listo, el body JSON, un 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 jamás sale del HSM
El provider clasifica los códigos de estado por su cuenta, 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 de TLS 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 del header 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);            // problema 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 vale la pena memorizar: devuelva ctsSuccess siempre que un servidor realmente haya respondido, incluso con 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 que se describe abajo. El constructor es estricto en la dirección contraria — lanza EHPDFCSCSignatureProviderError si falta el transporte, si CredentialID está vacío, si no se suministra ni un AccessToken ni un callback de token, si un presupuesto queda fuera de rango, o si ServiceBaseURL no es HTTPS. El http:// plano solo se acepta con AllowInsecureHTTP, que pertenece a un banco de pruebas y a ningún otro lado

¿Qué es el SAD y por qué HotPDF lo descarta después de un 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 es aceptado, aun cuando la firma misma llegue después vía polling asíncrono. El SAD es la prueba del servicio de que el firmante aprobó estos hashes en particular, y un SAD que queda vagando en memoria es una autorización esperando gastarse en el documento equivocado

Con los defaults de THPDFCSCOptions.Default — RequireSAD y AutoAuthorize ambos en True — el provider carga credentials/info una vez, le pide a su THPDFCSCAuthenticationCallback los valores authData (un OTP, un PIN, lo que exija el bloque auth de la credencial), y envía credentials/authorize. Un 200 trae el SAD directo; un 202 trae un handle que se sondea vía credentials/authorizeCheck hasta MaxPollAttempts (60) veces a PollIntervalMS (250 ms). El callback puede devolver a lo sumo 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 sea aceptado, un SAD obtenido automáticamente se conserva para que el mismo lote pueda reintentarse sin volver a preguntarle al firmante

Diagrama del ciclo de vida del SAD de HotPDF: con RequireSAD y AutoAuthorize el provider carga credentials/info una vez, le pide al callback de autenticación valores 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 es aceptado, conservando un SAD obtenido si la red se cayó antes de la aceptación
Un SAD que queda en memoria es una autorización esperando gastarse en el documento equivocado, y un SAD preestablecido pasado vía options se usa solo para un request de un solo hash
var
  Options: THPDFCSCOptions;
  Provider: THPDFCSCSignatureProvider;
begin
  Options := THPDFCSCOptions.Default;   // RequireSAD, AutoAuthorize, modo async, 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 un 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 mismo pasa vía Options.SAD se comporta distinto, y a propósito. HotPDF no puede saber para qué hashes fue emitido, así que el provider usa un SAD preestablecido solo para un request 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 arma ambos bodies 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. Haga un loop del método Sign de un solo hash cuarenta veces y obtiene cuarenta autorizaciones; envíe un authorize y un signHash que no concuerden y el servicio puede consumir el SAD contra el lote equivocado

Antes de cualquier tráfico de red, el provider valida el lote. Cada request debe ser un digest (sikDigest) de 1 a 1,024 bytes con un OID de digest, y todos los requests deben compartir un mismo OID de algoritmo de firma, un mismo OID de digest y, para RSASSA-PSS, una misma longitud de salt. Un lote multi-hash también carga credentials/info y devuelve spsUnsupported cuando el valor multisign de la credencial es menor que el lote. El SAD entonces se ancla a una huella del lote — un SHA-256 sobre una etiqueta de versión, el conteo y, por request, 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 lugar y es un lote distinto que necesita una autorización nueva

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] corresponde a Digests[I]; el conteo se chequeó contra el request
end;

Para RSASSA-PSS el provider también envía signAlgoParams, una estructura DER RSASSA-PSS-params en base64 con el algoritmo de hash, MGF1 y la longitud de salt. Armarla implica codificar OIDs, y la versión 2.748.5 arregló una esquina de eso: X.690 §8.19.4 pliega los primeros dos arcos en un solo 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 multi-byte 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é un request reintentado no produce una segunda firma?

THPDFCSCSignatureProvider hace que cada llamada reintentable lleve una idempotency key 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 IDs de operación largos que compartan prefijo colisionarían con truncamiento, 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 de request compartido es estrecha a propósito:

  • Un HTTP 401 fuerza exactamente un refresco de token vía el callback de access token, y luego el request se repite una vez cuando hay un callback de access token asignado; un segundo 401 es definitivo
  • Las demás 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 (default 2), esperando Retry-After o RetryBaseDelayMS × 2attempt (base 100 ms), con tope en MaxRetryAfterMS (5,000 ms)
  • Las esperas corren en rebanadas de 25 ms que chequean Cancel, así que un usuario que aborta no se aguanta cinco segundos de back-off
  • signHash solo se reintenta mientras EnableIdempotency está activo; apáguelo y un timeout después del envío es definitivo, porque nadie puede saber si la clave ya se usó
Diagrama de política de reintentos de HotPDF: cada llamada reintentable lleva una idempotency key determinista csc- hasheada desde el 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 una falla temporal de transporte se reintentan hasta un RetryLimit de 2 esperando Retry-After o un backoff exponencial con tope de 5,000 ms
Los lotes completados se cachean por identificador de operación, credencial y huella, y en modo asíncrono el responseID almacenado permite que una llamada repetida retome el polling en lugar de reenviar el hash

La firma asíncrona (operationMode “A”, el default) agrega una guarda más: el responseID se almacena antes de sondear signatures/signPolling, así que una llamada repetida con el mismo identificador de operación retoma el polling en lugar de reenviar. Los lotes completados quedan en un caché indexado por identificador de operación, credencial y huella, con tope de MaxOperationCacheEntries (128) y devueltos como copias profundas. Ese caché vive en la instancia del provider y no sobrevive a un reinicio. La idempotency key sí, porque se deriva en vez de ser 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?

Pásele el provider a HPDFCMSSignPDFStreamWithProvider junto con el certificado end-entity de GetCertificateChain; HotPDF arma 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 providers es el mismo que cubre el artículo de signature providers enchufables 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 el certificado end-entity primero
  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 cuando usted lo fija y, si no, el tamaño del módulo RSA según la longitud de clave de la credencial. Para ECDSA fije EstimatedSignatureBytes por su cuenta, o la estimación reporta spsUnsupported. La variante de firma con tamaño automático vuelve a firmar cuando un placeholder resulta demasiado pequeño, y solo lo hace con providers que anuncian spcSafeSignRetry — cosa que THPDFCSCSignatureProvider hace únicamente mientras EnableIdempotency está activo. Para flujos PAdES-B-T, TimestampDigest pide un timestamp token 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 completo de signed attributes (sikMessage), y el validador de lotes lo rechaza como malformado, porque signHash es por definición hash-based. La unidad del provider compila bajo Free Pascal con tipos de función simples en lugar de métodos anónimos, pero los builders de CMS manejados por provider hoy lanzan excepción bajo FPC, así que incrustar una firma CSC en un PDF es camino de Delphi

Tampoco decide políticas. CredentialInfo reporta el estado de la clave, el estado del certificado, el modo de autorización, el nivel SCAL y el límite multisign, pero el provider no va a rechazar por su cuenta una clave deshabilitada o una credencial SCAL1 — cheque eso antes de mostrarle el prompt de OTP a un firmante. Y una instancia del provider firma un lote a la vez: SignHashBatch se serializa internamente para que dos hilos no compitan por un SAD, lo que significa que el throughput viene del batching, no de compartir un provider entre worker threads. Que la firma resultante sea calificada depende del servicio de confianza y su credencial, no de la biblioteca que llevó el hash hasta ahí

El provider CSC, los builders de CMS y PAdES y los providers local y PKCS#11 vienen todos en el HotPDF Delphi PDF component