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
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
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
ctsPermanentFailureterminan la llamada conspsProviderError, y elerror_descriptiondel servicio aterriza enLastError - 408, 429, 5xx y
ctsTemporaryFailurese reintentan hastaRetryLimit(default 2), esperandoRetry-AfteroRetryBaseDelayMS× 2attempt (base 100 ms), con tope enMaxRetryAfterMS(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 signHashsolo se reintenta mientrasEnableIdempotencyestá activo; apáguelo y un timeout después del envío es definitivo, porque nadie puede saber si la clave ya se usó
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