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
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
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
ctsPermanentFailureterminan la llamada conspsProviderError, y elerror_descriptiondel servicio aterriza enLastError - 408, 429, 5xx y
ctsTemporaryFailurese reintentan hastaRetryLimit(2 por defecto), esperando aRetry-Aftero aRetryBaseDelayMS× 2attempt (base de 100 ms), con tope enMaxRetryAfterMS(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 signHashsolo se reintenta conEnableIdempotencyactivado; apáguelo y un timeout tras el envío es definitivo, porque nadie puede saber si la clave ya se usó
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