Artículo técnico

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

Una solicitud signatures/signHash a un servicio de firma remota puede expirar cuando el HSM ya produjo la firma, y el cliente no puede saber en cuál lado de esa línea cayó. 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 un hash por llamada y el firmante teclea cuarenta contraseñas de un solo uso

Cualquier integración con el Cloud Signature Consortium (CSC) tiene que resolver ambos. Los proveedores de servicios de confianza que emiten claves cualificadas cada vez más entregan 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 como funciona la firma por el almacén de certificados de Windows vía CNG y CAPI. La clave se queda en el HSM del proveedor, y cada firma es un viaje de ida y vuelta por HTTPS

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

HotPDF firma vía un servicio CSC remoto con THPDFCSCSignatureProvider, un signature provider que corre 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, los token vaults y el registro de solicitudes viven todos en la capa HTTP, y difieren en cada despliegue

Para cada solicitud el provider arma un THPDFCSCTransportRequest y llama a su función THPDFCSCTransport. El record carga el URL completo, un header bearer Authorization listo, el Body JSON, una IdempotencyKey, el OperationIdentifier y el número de Attempt. Su función llena un THPDFCSCTransportResponse con StatusCode, Body y RetryAfterMS, y luego devuelve 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

Un transporte armado sobre el THTTPClient del RTL necesita unas cuarenta líneas. El body sale como bytes crudos, así que el JSON nunca pasa por una conversión de página de códigos:

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 del header como lo documente 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: reintenable
        end;
        Response.StatusCode := HttpResp.StatusCode; // pase 429 y 503 intactos
        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 memorizar: devuelva ctsSuccess siempre que un server contestó, aunque sea un 503. El provider clasifica los códigos de estado por su cuenta, y un transporte que mapea un 429 a ctsPermanentFailure apaga la lógica de reintentos descrita abajo. Los bodies mayores que MaxResponseBytes (2 MB por default) 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 el URL no es HTTPS. El http:// plano pasa solo con AllowInsecureHTTP asignado, y ese flag pertenece a un rig 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 es aceptado, incluso cuando la firma misma llega después vía polling asíncrono. El SAD es la prueba del servicio de que el firmante aprobó estos hashes en particular. Uno que queda viviendo 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 que exige el bloque auth de la credencial, normalmente un OTP o un PIN, y envía credentials/authorize. Una respuesta 200 trae el SAD directo. Una 202 trae un handle, que el provider sondea vía credentials/authorizeCheck hasta MaxPollAttempts (60) veces a PollIntervalMS (250 ms) salvo que el servicio mande Retry-After. El callback puede devolver cuando mucho 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 sea aceptado, un SAD obtenido automáticamente permanece en su lugar, todavía atado al mismo lote, así que el reintento sale sin un segundo prompt 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, 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
      // 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 en caché falta o está a menos de TokenRefreshSkewSeconds (30) del expiry 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 preestablecido se usa solo para una solicitud de un solo hash. Para un lote multi-hash con AutoAuthorize encendido descarta el valor preestablecido y autoriza el lote; con AutoAuthorize apagado falla con "CSC SAD is not pinned to the requested hash batch" en vez de adivinar. Los servicios que autorizan solo por el scope de OAuth y jamás emiten un SAD funcionan con RequireSAD en False, en cuyo caso el miembro SAD se deja fuera del body de signHash

Firmar cuarenta facturas con un solo prompt de OTP

SignHashBatch envía un credentials/authorize y un signatures/signHash para hasta MaxBatchSignatures (64) digests, y arma ambos bodies desde el mismo array, así que numSignatures, el orden de hashes y hashAlgorithmOID coinciden entre las dos llamadas. Loopee el método Sign de un solo hash cuarenta veces y obtiene cuarenta autorizaciones. Envíe un authorize y un signHash que discrepen y el servicio puede gastar el SAD contra el lote equivocado

La validación corre antes de cualquier tráfico de red. Cada solicitud tiene que ser un digest (sikDigest) de 1 a 1,024 bytes con un OID de digest, y todas las solicitudes deben compartir un algoritmo de firma, un OID de digest y, para RSASSA-PSS, una longitud de salt. Un lote multi-hash además carga credentials/info y devuelve spsUnsupported cuando el valor multisign de la credencial es menor que el tamaño del lote. La autorización queda entonces atada a un fingerprint SHA-256 sensible al orden de la lista de solicitudes, así que intercambiar dos hashes arma 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 computó
  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 record de corrida, 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 regresa del caché, un job async pendiente
    // retoma el polling, una solicitud 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] le corresponde a Digests[I]; el conteo se contrastó contra la solicitud
end;

Para RSASSA-PSS el provider agrega signAlgoParams, una estructura DER RSASSA-PSS-params en base64 que carga el algoritmo de hash, MGF1 y la longitud de salt. X.690 §8.19.4 pliega los primeros dos arcos de OID en un valor (40 × primero + segundo), y bajo la raíz 2 un segundo arco arriba de 39 lo empuja más allá de 127, donde necesita la forma multibyte base-128. Ningún OID SHA-2 ejercita ese camino, ya que 2.16 se pliega a 96, así que un encoder puede pasar todas las pruebas 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é un request reintentado no produce una segunda firma?

THPDFCSCSignatureProvider le da a cada llamada reintenable una idempotency key determinista y guarda en caché los resultados completados, así que un reintento tras una respuesta perdida devuelve las firmas originales en vez de pedirle al HSM unas nuevas. La clave es csc- seguida del hex SHA-256 del identificador de operación y la fase del protocolo, y para la autorización y signHash la fase incluye el fingerprint del lote. Hashear en vez de truncar mantiene la clave de longitud fija y evita que dos IDs de operación largos con un prefijo compartido colisionen

La política de reintentos en el camino compartido de solicitudes es angosta:

  • Un HTTP 401 fuerza exactamente un refresco de token vía el callback de access token, y la solicitud se repite una vez; un segundo 401 es final, y sin callback de token el primero lo es
  • Las demás respuestas 4xx y ctsPermanentFailure terminan la llamada con spsProviderError, y el error_description (o error) del servicio cae en LastError
  • 408, 429, 5xx y ctsTemporaryFailure se reintentan hasta RetryLimit (default 2), esperando Retry-After o RetryBaseDelayMS (100 ms) doblado en cada intento, con tope en MaxRetryAfterMS (5,000 ms)
  • Las esperas corren en tajadas de 25 ms que revisan Cancel, así que un usuario que aborta no se sienta a esperar un back-off de cinco segundos
  • signHash se reintenta solo mientras EnableIdempotency está encendido; apáguelo y un timeout después del envío es final, porque nadie puede saber si el hash ya estaba firmado
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, con un validity_period de AsyncValidityPeriodMS, 60,000 ms) agrega una guarda 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 volver a enviar el hash. Los lotes completados se cachean por instancia de provider, hasta MaxOperationCacheEntries (128), y ese caché desaparece tras un reinicio. La idempotency key sobrevive, porque se deriva en vez 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 recurre al OperationIdentifier de las solicitudes, luego a un GUID nuevo. Un GUID nuevo por llamada significa que su propio bucle de reintentos arranca una operación nueva cada vez: slot de caché nuevo, idempotency key nueva, y posiblemente un segundo prompt de OTP. También significa que Provider.Cancel no tiene nada a qué apuntar, porque su código jamás vio el GUID. Derive el identificador de algo que ya persista, como el número de lote o el ID del documento, y deje EnableIdempotency en su default de True. Si un servicio rechaza el header, quítelo en el transporte en vez de apagar la opción

Embeber 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 atributos firmados. El PDF de entrada necesita el placeholder de /ByteRange y /Contents que THPDFPage.AddSignedSignatureField escribe, exactamente como en el flujo de firma PAdES en HotPDF, y el modelo de provider es el mismo detrás de los 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;

Para el dimensionado del placeholder, EstimateSignatureSize devuelve EstimatedSignatureBytes si usted lo asigna, y si no el tamaño del módulo RSA derivado de la longitud de clave en credentials/info. Para claves ECDSA responde spsUnsupported y el estimador del placeholder recurre al certificado, así que asignar EstimatedSignatureBytes usted mismo da un resultado predecible. HPDFCMSSignPDFStreamWithProviderAutoSize re-arma y re-firma cuando un placeholder resulta chico, y lo hace solo para providers que anuncian spcSafeSignRetry. THPDFCSCSignatureProvider lo anuncia mientras EnableIdempotency está encendido, que es una razón más para dejar ese default quieto

Para PAdES-B-T, TimestampDigest le 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 signature provider el mensaje completo de atributos firmados (sikMessage), y el validador de lotes lo rechaza como malformado, porque signHash es basado en hash por definición. Bajo Free Pascal la unit del provider compila con tipos de función planos en lugar de anonymous methods, así que SignHashBatch y TimestampDigest son llamables ahí; embeber 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 una credencial SCAL1 es su decisión rechazarla. Revise esos campos justo después de RefreshCredentialInfo y antes de que el firmante vea jamás un prompt de OTP; un rechazo del servicio después de que el firmante tecleó un código es peor experiencia que un error claro de entrada

Una instancia de provider corre un lote a la vez, porque SignHashBatch, RefreshCredentialInfo y TimestampDigest comparten un lock para que dos threads no se disputen un SAD. El throughput sale de lotes más grandes, hasta el límite multisign de la credencial. Si la firma resultante cuenta como cualificada lo deciden el servicio de confianza y la credencial que emitió

El provider CSC se distribuye junto con los constructores CMS y PAdES y los signature providers locales y PKCS#11 en el componente PDF de HotPDF para Delphi, así que la firma remota, por token y por archivo alimentan todos las mismas llamadas de firma PDF