Artículo técnico

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

Una petición signatures/signHash a un servicio de firma remoto puede agotar su tiempo después de que el HSM ya produjo la firma, y el cliente no puede saber en qué lado de esa línea aterrizó. Reintente a ciegas y una aprobación puede producir dos firmas sobre el mismo contrato. Se niegue a reintentar y un lote nocturno muere por un solo 503. Firme cuarenta facturas de un hash en un tiempo y el firmante teclea cuarenta contraseñas de un solo uso

Cualquier integración de Cloud Signature Consortium (CSC) tiene que resolver ambos. Los prestadores de servicios de confianza que emiten claves cualificadas reparten cada vez más un endpoint de API CSC y un cliente OAuth en lugar de un archivo PFX o un token USB, así que no hay nada que importar a un almacén local a la manera en que funciona firmar con el almacén de certificados de Windows vía CNG y CAPI. La clave se queda en el HSM del prestador, y cada firma es un viaje de ida y vuelta por HTTPS

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

HotPDF firma a través de un servicio CSC remoto con THPDFCSCSignatureProvider, un provider de firma que ejecuta el protocolo CSC v2 (credential info, autorización, signatures/signHash, polling) mientras su código Delphi es dueño del transporte HTTP y del token OAuth. Los proxies, el TLS pinning, los certificados de cliente, las cajas fuertes de tokens y el logging de peticiones viven todos en la capa HTTP, y difieren en cada despliegue

Para cada petición el provider construye un THPDFCSCTransportRequest y llama a su función THPDFCSCTransport. El registro lleva la URL completa, una cabecera Authorization bearer lista, el Body JSON, una IdempotencyKey, el OperationIdentifier y el número de Attempt. Su función rellena un THPDFCSCTransportResponse con StatusCode, Body y RetryAfterMS, y luego devuelve 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

Un transporte construido sobre el THTTPClient de la RTL necesita unas cuarenta líneas. El body sale como bytes en crudo, así que el JSON jamás pasa por una conversión 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   // 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 DNS, TLS o socket: reintentable
        end;
        Response.StatusCode := HttpResp.StatusCode; // pase 429 y 503 sin tocar
        SetLength(Response.Body, Reply.Size);
        if Reply.Size > 0 then
          Move(Reply.Memory^, Response.Body[1], Reply.Size);
        // Retry-After en segundos; un valor HTTP-date cae a 0 aquí
        Response.RetryAfterMS := StrToIntDef(HttpResp.HeaderValue['Retry-After'], 0) * 1000;
        Result := ctsSuccess;
      finally
        Reply.Free;
        Body.Free;
      end;
    end;
end;

La única regla que hay que memorizar: devuelva ctsSuccess siempre que un servidor haya contestado, aunque sea con un 503. El provider clasifica los códigos de estado por su cuenta, y un transporte que mapee un 429 a ctsPermanentFailure apaga la lógica de reintentos descrita abajo. Los bodies mayores que MaxResponseBytes (2 MB por defecto) se rechazan con spsMalformed devuelva lo que devuelva el transporte

El constructor es estricto en la otra dirección. Lanza EHPDFCSCSignatureProviderError cuando falta el transporte, ServiceBaseURL o CredentialID está vacío, no se suministra ni un AccessToken ni un callback de token, un presupuesto está fuera de rango, o la URL no es HTTPS. El http:// llano pasa solo con AllowInsecureHTTP activado, y ese flag pertenece a un banco de pruebas apuntado a un servicio mock local

¿Qué es el SAD y por qué es de 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. Uno que merodea en memoria es una autorización esperando gastarse en el documento equivocado

Con los valores por defecto de THPDFCSCOptions.Default (RequireSAD y AutoAuthorize ambos True), el provider carga credentials/info una vez, pide a su THPDFCSCAuthenticationCallback los valores de authData que el bloque auth del credential exija, normalmente un OTP o un PIN, y envía credentials/authorize. Una respuesta 200 trae el SAD directamente. Un 202 trae un handle, que el provider sondea vía credentials/authorizeCheck hasta MaxPollAttempts (60) veces a PollIntervalMS (250 ms) salvo que el servicio envíe Retry-After. 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

La limpieza ocurre después de la aceptación. Si la red se cae antes de que signHash se acepte, un SAD obtenido automáticamente se queda en su sitio, todavía atado al mismo lote, así que el reintento sale sin un segundo diálogo de OTP. El firmante aprobó una vez; el provider gasta esa aprobación una vez

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
      // TokenVault es su propio cliente OAuth; ForceRefresh es True tras un 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   // su propia UI
        Exit(spsCancelled);
      SetLength(Values, 1);
      Values[0].ID := 'otp';
      Values[0].Value := AnsiString(Otp);
      Result := spsValid;
    end);

El callback de token se dispara cuando el token cacheado falta o está a menos de TokenRefreshSkewSeconds (30) del vencimiento que usted reportó; un ExpiresAtUTC de cero significa que el token se reutiliza hasta que un 401 fuerza un refresco

Un SAD que usted pasa por su cuenta vía Options.SAD se comporta distinto, y a propósito. Nada le dice al provider qué hashes cubre ese SAD, así que un SAD preajustado se usa solo para una petición de un solo hash. Para un lote multi-hash con AutoAuthorize activado descarta el valor preajustado y autoriza el lote; con AutoAuthorize apagado falla con "CSC SAD is not pinned to the requested hash batch" en lugar de adivinar. Los servicios que autorizan solo por el scope de OAuth y jamás emiten un SAD funcionan con RequireSAD puesto a False, en cuyo caso el miembro SAD se queda fuera del body de signHash

Firmar cuarenta facturas con un solo diálogo de OTP

SignHashBatch envía un credentials/authorize y un signatures/signHash para hasta MaxBatchSignatures (64) digests, y construye ambos cuerpos desde el mismo array, así que numSignatures, el orden de hashes y hashAlgorithmOID concuerdan entre las dos llamadas. 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

La validación corre antes de cualquier tráfico de red. Cada petición tiene que ser un digest (sikDigest) de 1 a 1.024 bytes con un OID de digest, y todas las peticiones deben compartir un 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 tamaño del lote. La autorización se ata entonces a una huella SHA-256 sensible al orden de la lista de peticiones, así que intercambiar dos hashes hace un lote distinto que necesita una aprobación fresca

var
  Requests: THPDFCSCSignatureRequests;
  Signatures: THPDFCSCSignatures;
  Status: THPDFSignatureProviderStatus;
  OpId: AnsiString;
  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;

  // Derive el ID de su propio registro de ejecución, jamás de un GUID fresco por llamada
  OpId := AnsiString('invoices-' + RunRecord.BatchNumber);
  Status := Provider.SignHashBatch(Requests, OpId, Signatures);
  if Status = spsProviderError then
    // Mismo OpId: un lote completado vuelve de la caché, un job asíncrono
    // pendiente retoma el sondeo, una petición no enviada reutiliza la misma clave
    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] pertenece a Digests[I]; el conteo se cotejó contra la petición
end;

Para RSASSA-PSS el provider añade signAlgoParams, una estructura DER en base64 RSASSA-PSS-params que lleva el algoritmo de hash, MGF1 y la longitud de salt. X.690 §8.19.4 pliega los dos primeros arcos de un OID 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. Ningún OID SHA-2 ejercita ese camino, ya que 2.16 se pliega a 96, así que un codificador puede pasar todos los tests SHA-2 y seguir estando mal. El provider escribe la forma multibyte y reporta un OID malformado como su propio EHPDFCSCSignatureProviderError en lugar de un EConvertError

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

THPDFCSCSignatureProvider da a cada llamada reintentable 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 del protocolo, y para la autorización y signHash la fase incluye la huella del lote. Hashear en lugar de truncar mantiene la clave de longitud fija y evita que dos IDs de operación largos con prefijo compartido colisionen

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 a través del callback de access token, y la petición se repite una vez; un segundo 401 es definitivo, y sin callback de token lo es el primero
  • El resto de respuestas 4xx y ctsPermanentFailure terminan la llamada con spsProviderError, y el error_description (o error) del servicio aterriza en LastError
  • 408, 429, 5xx y ctsTemporaryFailure se reintentan hasta RetryLimit (2 por defecto), esperando a Retry-After o a RetryBaseDelayMS (100 ms) doblado en cada intento, 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, con un validity_period de AsyncValidityPeriodMS, 60.000 ms) añade una guardia más. El responseID se guarda antes de que el provider empiece a sondear signatures/signPolling, así que una llamada repetida con el mismo identificador de operación retoma el job pendiente en lugar de reenviar el hash. Los lotes completados se cachean por instancia de provider, hasta MaxOperationCacheEntries (128), y esa caché se esfuma tras un reinicio. La clave de idempotencia sobrevive, porque se deriva en lugar de ser aleatoria: un proceso reiniciado que reutiliza su identificador de operación envía la misma clave, y si el servicio deduplica sobre ella es parte del contrato del servicio

Todo esto pende del identificador de operación, y el parámetro es fácil de dejar vacío. Pase '' a SignHashBatch y el provider recae en el OperationIdentifier de las peticiones, y luego en un GUID nuevo. Un GUID nuevo por llamada significa que su propio bucle de reintentos arranca una operación nueva cada vez: nueva ranura de caché, nueva clave de idempotencia, y posiblemente un segundo diálogo de OTP. También significa que Provider.Cancel no tiene nada que apuntar, porque su código jamás vio el GUID. Derive el identificador de algo que usted ya persiste, como el número de lote o el ID de documento, y mantenga EnableIdempotency en su valor por defecto de True. Si un servicio rechaza la cabecera, suértela en el transporte en lugar de apagar la opción

¿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;

Para dimensionar el placeholder, EstimateSignatureSize devuelve EstimatedSignatureBytes cuando usted lo fija, y en caso contrario el tamaño del modulus RSA derivado de la longitud de la clave en credentials/info. Para claves ECDSA responde spsUnsupported y el estimador del placeholder recae en el certificado, así que fijar EstimatedSignatureBytes usted mismo da un resultado previsible. HPDFCMSSignPDFStreamWithProviderAutoSize reconstruye y refirma cuando un placeholder resulta demasiado pequeño, y solo lo hace con providers que anuncian spcSafeSignRetry. THPDFCSCSignatureProvider lo anuncia mientras EnableIdempotency está activado, que es una razón más para dejar ese valor por defecto en paz

Para PAdES-B-T, TimestampDigest pide al mismo servicio un token RFC 3161 vía signatures/timestamp, con un nonce opcional y un tope de tamaño de MaxTimestampBytes (1 MB)

Decisiones de política que se quedan en su código

El provider firma digests. Ed25519 y Ed448 en modo puro le entregan a un provider de firma 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. Bajo Free Pascal la unidad del provider compila con tipos de función llanos en lugar de métodos anónimos, así que SignHashBatch y TimestampDigest son invocables allí; incrustar el resultado en un PDF vía HPDFCMSSignPDFStreamWithProvider es un camino Delphi

El provider también deja la política en sus manos. CredentialInfo reporta KeyStatus, CertificateStatus, AuthorizationMode, SCAL y MultiSignatures, pero una clave deshabilitada o un credential SCAL1 es decisión suya de rechazar. Compruebe esos campos justo después de RefreshCredentialInfo y antes de que el firmante vea jamás un diálogo de OTP; un rechazo del servicio después de que el firmante tecleó un código es una experiencia peor que un error claro de entrada

Una instancia de provider corre un lote por vez, porque SignHashBatch, RefreshCredentialInfo y TimestampDigest comparten un lock para que dos hilos no puedan competir por un SAD. El throughput sale de lotes más grandes, hasta el límite multisign del credential. Que la firma resultante cuente como cualificada lo deciden el servicio de confianza y el credential que emitió

El provider CSC llega junto a los builders CMS y PAdES y los providers de firma locales y PKCS#11 en el componente Delphi PDF de HotPDF, así que la firma remota, por token y por archivo alimentan todos las mismas llamadas de firma PDF